diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index 56726817dc..95c9b959a1 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -71,7 +71,7 @@ jobs: - name: "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }})" if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' run: | - echo "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }}):" >> $GITHUB_STEP_SUMMARY + echo "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }}):" | tee -a ${GITHUB_STEP_SUMMARY} BIN_PACKAGE=$(ls "${GITHUB_WORKSPACE}/artifacts/" | grep "${PKG_NAME}[0-9]*_${{ steps.environment_step.outputs.KEYMAN_VERSION }}-1${{ steps.environment_step.outputs.PRERELEASE_TAG }}+$(lsb_release -c -s)1_amd64.deb") cd ${{ github.workspace }}/keyman/linux @@ -80,7 +80,7 @@ jobs: --bin-pkg "${GITHUB_WORKSPACE}/artifacts/${BIN_PACKAGE}" \ --git-sha "${{ steps.environment_step.outputs.GIT_SHA }}" \ --git-base "${{ steps.environment_step.outputs.GIT_BASE }}" \ - verify 2>> $GITHUB_STEP_SUMMARY + verify 2> >(tee -a ${GITHUB_STEP_SUMMARY} >&2) - name: Archive .symbols file if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' && always() diff --git a/HISTORY.md b/HISTORY.md index f2d3cab56b..e8a1a9b22a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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) diff --git a/VERSION.md b/VERSION.md index a0b5f4d5f2..45d34a1a82 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.248 \ No newline at end of file +19.0.252 \ No newline at end of file diff --git a/android/KMAPro/kMAPro/build.gradle b/android/KMAPro/kMAPro/build.gradle index 77ab2fa736..ebdbe2a2f3 100644 --- a/android/KMAPro/kMAPro/build.gradle +++ b/android/KMAPro/kMAPro/build.gradle @@ -173,10 +173,11 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.google.android.material:material:1.12.0' implementation 'com.stepstone.stepper:material-stepper:4.3.1' + implementation 'androidx.webkit:webkit:1.14.0' implementation files('libs/keyman-engine.aar') implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' - implementation "com.android.installreferrer:installreferrer:2.2" + implementation 'com.android.installreferrer:installreferrer:2.2' // Add dependency for generating QR Codes // (Even though it's embedded in KMEA, because we're manually copying keyman-engine.aar, diff --git a/android/KMEA/app/build.gradle b/android/KMEA/app/build.gradle index c9ae92c1c2..4bb05106e3 100644 --- a/android/KMEA/app/build.gradle +++ b/android/KMEA/app/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation 'commons-io:commons-io:2.16.1' implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' // Robolectric testImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 9152f84360..541e20edcb 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -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 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; diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index 853d997d7e..f043a56681 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -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(); } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index f87219f512..3ca6f53de0 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -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"; } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index 0dbfe5d023..844959fb54 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -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; } /** diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index 0586ecbcc8..11c64434da 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -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 diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java index 91fc7dd370..afc206c9da 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java @@ -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>(); @@ -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); diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java index 342934200a..bfb867ecc9 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java @@ -26,37 +26,48 @@ public class WebViewUtilsTest { @Test public void test_ChromeEmpty_EngineWebViewVersionStatusDisabled() { String chromeVersion = ""; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DISABLED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DISABLED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome36_EngineWebViewVersionStatusDisabled() { double chromeVersionFloat = Float.parseFloat(WebViewUtils.KEYMAN_MIN_TARGET_VERSION_DEGRADED_ANDROID_CHROME) - 1.0; String chromeVersion = String.valueOf(chromeVersionFloat); - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DISABLED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DISABLED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome37_EngineWebViewVersionStatusDegraded() { String chromeVersion = WebViewUtils.KEYMAN_MIN_TARGET_VERSION_DEGRADED_ANDROID_CHROME; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DEGRADED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DEGRADED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome94_EngineWebViewVersionStatusDegraded() { double chromeVersionFloat = Float.parseFloat(WebViewUtils.KEYMAN_MIN_TARGET_VERSION_ANDROID_CHROME) - 1.0; String chromeVersion = String.valueOf(chromeVersionFloat); - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DEGRADED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DEGRADED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome95_EngineWebViewVersionStatusFull() { String chromeVersion = WebViewUtils.KEYMAN_MIN_TARGET_VERSION_ANDROID_CHROME; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.FULL); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.FULL, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } + + @Test + public void test_buildAssetUrl() { + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl("")); + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl(null)); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo", WebViewUtils.buildAssetUrl("foo")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("/foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/bar.html", WebViewUtils.buildAssetUrl("foo/bar.html")); + } + } diff --git a/android/Samples/KMSample1/app/build.gradle b/android/Samples/KMSample1/app/build.gradle index ae4ff55d7e..395d6a69e5 100644 --- a/android/Samples/KMSample1/app/build.gradle +++ b/android/Samples/KMSample1/app/build.gradle @@ -50,4 +50,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Samples/KMSample2/app/build.gradle b/android/Samples/KMSample2/app/build.gradle index a148a7ab19..40e30c0025 100644 --- a/android/Samples/KMSample2/app/build.gradle +++ b/android/Samples/KMSample2/app/build.gradle @@ -49,4 +49,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Tests/KeyboardHarness/app/build.gradle b/android/Tests/KeyboardHarness/app/build.gradle index 25d5c8250a..d9502cfa64 100644 --- a/android/Tests/KeyboardHarness/app/build.gradle +++ b/android/Tests/KeyboardHarness/app/build.gradle @@ -60,4 +60,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java b/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java index becec2868a..a41d79dd89 100644 --- a/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java +++ b/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java @@ -58,6 +58,7 @@ public class MainActivity extends BaseActivity implements OnKeyboardEventListene KMManager.KMDefault_KeyboardFont, KMManager.KMDefault_KeyboardFont); KMManager.addKeyboard(this, chiralityKBInfo); + Keyboard.setDefaultKeyboard(chiralityKBInfo); // Longpress test keyboard Keyboard longpressKBbInfo = new Keyboard( diff --git a/common/windows/delphi/general/Upload_Settings.pas b/common/windows/delphi/general/Upload_Settings.pas index 8811ec6f10..2763a042be 100644 --- a/common/windows/delphi/general/Upload_Settings.pas +++ b/common/windows/delphi/general/Upload_Settings.pas @@ -86,6 +86,8 @@ function API_UserAgent: string; // = 'Keyman for Windows/...' function API_UserAgent_Developer: string; // = 'Keyman Developer/...' function API_UserAgent_Diagnostics: string; +function API_Path_Keyboard(const id: string): string; + function KeymanCom_Protocol_Server: string; // = 'https://keyman.com'; function MakeAPIURL(path: string): string; @@ -121,6 +123,8 @@ const S_KeymanCom_Staging = 'https://keyman.com'; // #7227 disabling: 'https://keyman-staging.com'; S_APIServer_Staging = 'api.keyman.com'; // #7227 disabling: 'api.keyman-staging.com'; + S_API_Path_Keyboard = '/keyboard/%0:s'; + const URLPath_PackageDownload_Format = '/go/package/download/%0:s?platform=windows&tier=%1:s&bcp47=%2:s&update=%3:d'; URL_KeymanDeveloper_HelpKmcMessage_Format = S_Host_KmnSh+'/%0:s'; @@ -187,4 +191,9 @@ begin Result := Format(URL_KeymanDeveloper_HelpKmcMessage_Format, [id.ToLower]); end; +function API_Path_Keyboard(const id: string): string; +begin + Result := Format(S_API_Path_Keyboard, [id]); +end; + end. diff --git a/developer/src/common/web/utils/src/index.ts b/developer/src/common/web/utils/src/index.ts index a414139584..5371620d67 100644 --- a/developer/src/common/web/utils/src/index.ts +++ b/developer/src/common/web/utils/src/index.ts @@ -78,3 +78,5 @@ export { getFontFamily, getFontFamilySync } from './font-family.js'; export * as ValidIds from './valid-ids.js'; export * as ProjectLoader from './project-loader.js'; + +export { optionsManager, KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath } from './keyman-developer-options.js'; diff --git a/developer/src/common/web/utils/src/keyman-developer-options.ts b/developer/src/common/web/utils/src/keyman-developer-options.ts new file mode 100644 index 0000000000..3117ab8633 --- /dev/null +++ b/developer/src/common/web/utils/src/keyman-developer-options.ts @@ -0,0 +1,128 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * User options for Keyman Developer. These are stored in options.json in the + * user profile; the location varies by operating system or may be stored in + * browser storage on web sites. + * + * The node-based loader is implemented in both kmc and Keyman Developer Server, + * in order to keep node dependencies out of the developer-utils module. + */ + +/** + * The standard path under the user profile where options.json is stored; use + * `path.join(os.homedir(), ...KeymanDeveloperOptionsPath)` or similar + */ +export const KeymanDeveloperOptionsPath = [/* '~', */ '.keymandeveloper', 'options.json']; + +/** + * The set of standard user options for Keyman Developer. Corresponds to + * TKeymanDeveloperOptions in `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(valueName: T): KeymanDeveloperOptions[T] { + return this.options[valueName]; + } + + public clear() { + this.options = {...DEFAULT_OPTIONS}; + } +} + +export const optionsManager = new KeymanDeveloperOptionsManager(); + diff --git a/developer/src/kmc/src/commands/buildClasses/BuildProject.ts b/developer/src/kmc/src/commands/buildClasses/BuildProject.ts index 76fdfe1d04..6d51a3914a 100644 --- a/developer/src/kmc/src/commands/buildClasses/BuildProject.ts +++ b/developer/src/kmc/src/commands/buildClasses/BuildProject.ts @@ -46,7 +46,7 @@ class ProjectBuilder { // Give a hint if the project is v1.0 if(this.project.options.version != '2.0') { - if(getOption("prompt to upgrade projects", true)) { + if(getOption("prompt to upgrade projects")) { this.callbacks.reportMessage(InfrastructureMessages.Hint_ProjectIsVersion10()); } } diff --git a/developer/src/kmc/src/util/KeymanSentry.ts b/developer/src/kmc/src/util/KeymanSentry.ts index 9ee05ecb7f..658f750d37 100644 --- a/developer/src/kmc/src/util/KeymanSentry.ts +++ b/developer/src/kmc/src/util/KeymanSentry.ts @@ -23,7 +23,7 @@ export class KeymanSentry { return true; } - return getOption('automatically report errors', true); + return getOption('automatically report errors'); } static init(options?: SentryNodeOptions) { diff --git a/developer/src/kmc/src/util/options.ts b/developer/src/kmc/src/util/options.ts index 9d1a93895e..51217628cf 100644 --- a/developer/src/kmc/src/util/options.ts +++ b/developer/src/kmc/src/util/options.ts @@ -1,69 +1,32 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Load Keyman Developer's options from the standard Options location. This + * small loader is duplicated in Keyman Developer Server, because we do not have + * a shared node-aware module at this time. + */ + import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath, optionsManager } from '@keymanapp/developer-utils'; -export interface KeymanDeveloperOptions { - "use tab char"?: boolean; - "link font sizes"?: boolean; - "indent size"?: number; - "use old debugger"?: boolean; - "editor theme"?: string; - "debugger break when exiting line"?: boolean; - "debugger single step after break"?: boolean; - "debugger show store offset"?: boolean; - "debugger recompile with debug info"?: boolean; - "debugger auto reset before compilng"?: boolean; - "auto save before compiling"?: boolean; - "osk auto save before importing"?: boolean; - "web host port"?: number; - "server keep alive"?: boolean; - "server use local addresses"?: boolean; - "server ngrok token"?: string; - "server ngrok region"?: string; - "server use ngrok"?: boolean; - "server show console window"?: boolean; - "char map disable database lookups"?: boolean; - "char map auto lookup"?: boolean; - "open keyboard files in source view"?: boolean; - "display theme"?: string; - "external editor path"?: string; - "smtp server"?: string; - "test email addresses"?: string; - "web ladder length"?: number; - "default project path"?: string; - "automatically report errors"?: boolean; - "automatically report usage"?: boolean; - "toolbar visible"?: boolean; - "active project"?: string; - "prompt to upgrade projects"?: boolean; -}; +let optionsLoaded: boolean = false; -type KeymanDeveloperOption = keyof KeymanDeveloperOptions; - -// Default has no options set, and unit tests will use the defaults (won't call -// `loadOptions()`) -let options: KeymanDeveloperOptions = {}; - -// We only load the options from disk once on first use -let optionsLoaded = false; - -export async function loadOptions(): Promise { +export async function loadOptions(): Promise { if(optionsLoaded) { - return options; + return true; } + optionsLoaded = true; - options = {}; try { - const optionsFile = path.join(os.homedir(), '.keymandeveloper', 'options.json'); + const optionsFile = path.join(os.homedir(), ...KeymanDeveloperOptionsPath); if(fs.existsSync(optionsFile)) { for(let i = 0; i < 5; i++) { try { - const data = JSON.parse(fs.readFileSync(optionsFile, 'utf-8')); - if(typeof data == 'object') { - options = data; - } - break; - } catch(e) { + const data = fs.readFileSync(optionsFile) as Uint8Array; + return optionsManager.load(data); + } catch(e: any) { if(e?.code == 'EBUSY') { await new Promise(resolve => setTimeout(resolve, 500)); } else { @@ -76,20 +39,20 @@ export async function loadOptions(): Promise { } catch(e) { // Nothing to report here, sadly -- because we cannot rely on Sentry at this // low level. - options = {}; } - optionsLoaded = true; - return options; + + optionsManager.clear(); + return false; } -export function getOption(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] { - return options[valueName] ?? defaultValue; +export function getOption(valueName: T): KeymanDeveloperOptions[T] { + return optionsManager.get(valueName); } /** * unit tests will clear options before running, for consistency */ export function clearOptions() { - options = {}; - optionsLoaded = true; + optionsLoaded = false; + return optionsManager.clear(); } \ No newline at end of file diff --git a/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas b/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas index 5f19d98a2a..4f3b9d4135 100644 --- a/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas +++ b/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas @@ -319,6 +319,23 @@ function TVisualKeyboardToTouchLayoutConverter.SetupModifierKeysForImportedLayou for l in p.Layers do begin + // Find the Shift key and assign the next layer + k := l.FindKeyById('K_SHIFT'); + if Assigned(k) then + begin + if l.id = 'default' then + begin + k.NextLayer := 'shift'; + end + else + begin + // All layers other than default will return to default layer + // when shift is pressed, because we do not currently map + // shift+other mod layers with use of the Shift key in the import + k.NextLayer := 'default'; + end; + end; + // Find the Ctrl key for the layer k := l.FindKeyById('K_LCONTROL'); if not Assigned(k) then diff --git a/developer/src/server/src/KeymanSentry.ts b/developer/src/server/src/KeymanSentry.ts index 9dba30a0d4..b531e80235 100644 --- a/developer/src/server/src/KeymanSentry.ts +++ b/developer/src/server/src/KeymanSentry.ts @@ -23,7 +23,7 @@ export class KeymanSentry { return true; } - return getOption('automatically report errors', true); + return getOption('automatically report errors'); } static init(options?: SentryNodeOptions) { diff --git a/developer/src/server/src/config.ts b/developer/src/server/src/config.ts deleted file mode 100644 index e60ec6d0a0..0000000000 --- a/developer/src/server/src/config.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { mkdirSync } from 'fs'; -import { loadJsonFile } from './load-json-file.js'; - -export class Configuration { - public readonly appDataPath: string; - public readonly cachePath: string; - public readonly cacheStateFilename: string; - public readonly lockFilename: string; - public readonly pidFilename: string; - public readonly configFilename: string; - - /* Configuration values - set in config.json by TIKE */ - - public readonly port: number; - - /* ngrok Configuration */ - - public readonly useNgrok: boolean; - public readonly ngrokToken: string; - public readonly ngrokVisible: boolean; - - public ngrokEndpoint: string = ''; - - constructor() { - - this.appDataPath = (process.env.APPDATA || - (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")) + - '/Keyman/Keyman Developer/Server/'; - this.cachePath = this.appDataPath + 'cache/'; - this.cacheStateFilename = this.appDataPath + 'cache.json'; - this.lockFilename = this.appDataPath + 'lock.json'; - this.pidFilename = this.appDataPath + 'pid.json'; - this.configFilename = this.appDataPath + 'config.json'; - - mkdirSync(this.cachePath, { recursive: true}); - - const cfg = loadJsonFile(this.configFilename); - - this.port = cfg?.port ?? 8008; - - // ngrok configuration - this.useNgrok = cfg?.useNgrok ?? false; - this.ngrokToken = cfg?.ngrokToken ?? ''; - this.ngrokVisible = cfg?.ngrokVisible ?? false; - } -}; - -export const configuration = new Configuration(); \ No newline at end of file diff --git a/developer/src/server/src/data.ts b/developer/src/server/src/data.ts index 6600d57b4d..8d2753ea21 100644 --- a/developer/src/server/src/data.ts +++ b/developer/src/server/src/data.ts @@ -1,5 +1,5 @@ import { writeFileSync } from 'fs'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import { loadJsonFile } from './load-json-file.js'; export interface DebugObject { @@ -97,7 +97,7 @@ export class SiteData { } private loadState() { - const state = loadJsonFile(configuration.cacheStateFilename); + const state = loadJsonFile(standardPaths.cacheStateFilename); this.loadDebugObject(DebugKeyboard, state?.keyboards, this.keyboards); this.loadDebugObject(DebugModel, state?.models, this.models); this.loadDebugObject(DebugFont, state?.fonts, this.fonts); @@ -106,7 +106,7 @@ export class SiteData { } public saveState() { - writeFileSync(configuration.cacheStateFilename, JSON.stringify(this, null, 2), 'utf-8'); + writeFileSync(standardPaths.cacheStateFilename, JSON.stringify(this, null, 2), 'utf-8'); } }; diff --git a/developer/src/server/src/handlers/api/debugobject/register.ts b/developer/src/server/src/handlers/api/debugobject/register.ts index a1f7a72cd4..bb28c87a58 100644 --- a/developer/src/server/src/handlers/api/debugobject/register.ts +++ b/developer/src/server/src/handlers/api/debugobject/register.ts @@ -2,7 +2,7 @@ import * as express from 'express'; import { DebugObject, isValidId, simplifyId } from "../../../data.js"; import * as fs from 'fs'; import * as crypto from 'crypto'; -import { configuration } from '../../../config.js'; +import { standardPaths } from '../../../standardPaths.js'; import chalk from 'chalk'; // We allow only 12 objects of each type in the cache @@ -41,7 +41,7 @@ export function apiRegisterFile (intf: new () => O, root: o.lastUse = new Date(); o.id = id; - o.filename = configuration.cachePath + o.filenameFromId(id); + o.filename = standardPaths.cachePath + o.filenameFromId(id); fs.writeFileSync(o.filename, file); o.sha256 = crypto.createHash('sha256').update(file).digest('hex'); diff --git a/developer/src/server/src/index.ts b/developer/src/server/src/index.ts index 36fd8431d8..e248b19625 100644 --- a/developer/src/server/src/index.ts +++ b/developer/src/server/src/index.ts @@ -5,12 +5,12 @@ import express from 'express'; import multer from 'multer'; import * as ws from 'ws'; import { KeymanSentry } from './KeymanSentry.js'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import { environment } from './environment.js'; import setupRoutes from './routes.js'; import { shutdown } from './shutdown.js'; import { initTray } from './tray.js'; -import { loadOptions } from './options.js'; +import { getOption, loadOptions } from './options.js'; const options = { ngrokLog: false, // Set this to true if you need to see ngrok logs in the console @@ -18,13 +18,13 @@ const options = { /* Lock file - report on PID and prevent multiple instances cleanly */ -console.log(`Starting Keyman Developer Server ${environment.versionWithTag}, listening on port ${configuration.port}.`); - // We need to load the Keyman Developer options before attempting to initialize // Sentry. `loadOptions` silently suppresses exceptions and returns a default // set of options if an error occurs. await loadOptions(); +console.log(`Starting Keyman Developer Server ${environment.versionWithTag}, listening on port ${getOption('web host port')}.`); + KeymanSentry.init(); try { await run(); @@ -80,10 +80,11 @@ export async function run() { let server = null; try { - server = app.listen(configuration.port); + server = app.listen(getOption("web host port")); } catch(err) { console.error(err); // TODO handle and cleanup EADDRINUSE, throw anything else + return; } /* Attach the web socket server */ @@ -96,14 +97,14 @@ export async function run() { /* Launch ngrok if enabled */ - configuration.ngrokEndpoint = ''; - if(configuration.useNgrok) { + standardPaths.ngrokEndpoint = ''; + if(getOption("server use ngrok")) { await startNGrok(); } /* Load the tray icon */ - tray.start(configuration.port, configuration.ngrokEndpoint); + tray.start(getOption("web host port"), standardPaths.ngrokEndpoint); } async function loadNGrok() { @@ -129,8 +130,8 @@ async function startNGrok() { let started = false; const listener = await ngrok.forward({ proto: 'http', - addr: configuration.port, - authtoken: configuration.ngrokToken, + addr: getOption("web host port"), + authtoken: getOption("server ngrok token"), onLogEvent: (msg: string) => { if(options.ngrokLog) { console.log(chalk.cyan(('\n'+msg).split('\n').join('\n[ngrok] ').trim())); @@ -139,19 +140,19 @@ async function startNGrok() { onStatusChange: (state: string) => { if(state == 'connected' && started) { // We only announce reconnection after initial start - configuration.ngrokEndpoint = listener.url() ?? ''; - console.log(chalk.blueBright('ngrok tunnel reconnected at %s'), configuration.ngrokEndpoint); + standardPaths.ngrokEndpoint = listener.url() ?? ''; + console.log(chalk.blueBright('ngrok tunnel reconnected at %s'), standardPaths.ngrokEndpoint); } else if(state == 'closed') { - configuration.ngrokEndpoint = ''; + standardPaths.ngrokEndpoint = ''; console.log(chalk.blueBright('ngrok tunnel closed')); } } }); started = true; - configuration.ngrokEndpoint = listener.url(); - console.log(chalk.blueBright('ngrok tunnel established at %s'), configuration.ngrokEndpoint); + standardPaths.ngrokEndpoint = listener.url() ?? ""; + console.log(chalk.blueBright('ngrok tunnel established at %s'), standardPaths.ngrokEndpoint); } catch(e) { - configuration.ngrokEndpoint = ''; + standardPaths.ngrokEndpoint = ''; console.error(chalk.red('ngrok tunnel failed to connect with an error: %s'), e); return false; } @@ -168,8 +169,8 @@ function getRunningInstancePid(pidFilename: string) { } function writeLockFile() { - const lockFilename = configuration.lockFilename.replaceAll(/[\\\/]/g, path.sep); - const pidFilename = configuration.pidFilename.replaceAll(/[\\\/]/g, path.sep); + const lockFilename = standardPaths.lockFilename.replaceAll(/[\\\/]/g, path.sep); + const pidFilename = standardPaths.pidFilename.replaceAll(/[\\\/]/g, path.sep); // console.debug(`Testing existence of ${lockFilename}`); if(fs.existsSync(lockFilename)) { diff --git a/developer/src/server/src/options.ts b/developer/src/server/src/options.ts index 45799ba5f2..a911283be3 100644 --- a/developer/src/server/src/options.ts +++ b/developer/src/server/src/options.ts @@ -1,70 +1,32 @@ -// TODO: this is duplicated in kmc +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Load Keyman Developer's options from the standard Options location. This + * small loader is duplicated in kmc, because we do not have a shared node-aware + * module at this time. + */ + import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath, optionsManager } from '@keymanapp/developer-utils'; -export interface KeymanDeveloperOptions { - "use tab char"?: boolean; - "link font sizes"?: boolean; - "indent size"?: number; - "use old debugger"?: boolean; - "editor theme"?: string; - "debugger break when exiting line"?: boolean; - "debugger single step after break"?: boolean; - "debugger show store offset"?: boolean; - "debugger recompile with debug info"?: boolean; - "debugger auto reset before compilng"?: boolean; - "auto save before compiling"?: boolean; - "osk auto save before importing"?: boolean; - "web host port"?: number; - "server keep alive"?: boolean; - "server use local addresses"?: boolean; - "server ngrok token"?: string; - "server ngrok region"?: string; - "server use ngrok"?: boolean; - "server show console window"?: boolean; - "char map disable database lookups"?: boolean; - "char map auto lookup"?: boolean; - "open keyboard files in source view"?: boolean; - "display theme"?: string; - "external editor path"?: string; - "smtp server"?: string; - "test email addresses"?: string; - "web ladder length"?: number; - "default project path"?: string; - "automatically report errors"?: boolean; - "automatically report usage"?: boolean; - "toolbar visible"?: boolean; - "active project"?: string; - "prompt to upgrade projects"?: boolean; -}; +let optionsLoaded: boolean = false; -type KeymanDeveloperOption = keyof KeymanDeveloperOptions; - -// Default has no options set, and unit tests will use the defaults (won't call -// `loadOptions()`) -let options: KeymanDeveloperOptions = {}; - -// We only load the options from disk once on first use -let optionsLoaded = false; - -export async function loadOptions(): Promise { +export async function loadOptions(): Promise { if(optionsLoaded) { - return options; + return true; } + optionsLoaded = true; - options = {}; try { - const optionsFile = path.join(os.homedir(), '.keymandeveloper', 'options.json'); + const optionsFile = path.join(os.homedir(), ...KeymanDeveloperOptionsPath); if(fs.existsSync(optionsFile)) { for(let i = 0; i < 5; i++) { try { - const data = JSON.parse(fs.readFileSync(optionsFile, 'utf-8')); - if(typeof data == 'object') { - options = data; - } - break; - } catch(e) { + const data = fs.readFileSync(optionsFile) as Uint8Array; + return optionsManager.load(data); + } catch(e: any) { if(e?.code == 'EBUSY') { await new Promise(resolve => setTimeout(resolve, 500)); } else { @@ -77,20 +39,20 @@ export async function loadOptions(): Promise { } catch(e) { // Nothing to report here, sadly -- because we cannot rely on Sentry at this // low level. - options = {}; } - optionsLoaded = true; - return options; + + optionsManager.clear(); + return false; } -export function getOption(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] { - return options[valueName] ?? defaultValue; +export function getOption(valueName: T): KeymanDeveloperOptions[T] { + return optionsManager.get(valueName); } /** * unit tests will clear options before running, for consistency */ export function clearOptions() { - options = {}; - optionsLoaded = true; + optionsLoaded = false; + return optionsManager.clear(); } \ No newline at end of file diff --git a/developer/src/server/src/routes.ts b/developer/src/server/src/routes.ts index 3876bf0d7a..acccf23402 100644 --- a/developer/src/server/src/routes.ts +++ b/developer/src/server/src/routes.ts @@ -12,9 +12,10 @@ import handleIncPackagesJson from './handlers/inc/packages-json.js'; import apiPackageRegister from './handlers/api/package/register.js'; import handleIncKeyboardsCss from './handlers/inc/keyboards-css.js'; import { Environment } from './version-data.js'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import chalk from 'chalk'; import { shutdown } from './shutdown.js'; +import { getOption } from './options.js'; export default function setupRoutes(app: express.Express, upload: multer.Multer, wsServer: ws.WebSocketServer, environment: Environment ) { @@ -163,7 +164,7 @@ export default function setupRoutes(app: express.Express, upload: multer.Multer, /* ngrok data */ app.get('/api/status', (_req,res,next) => { - const response = { ngrokEnabled: configuration.useNgrok, ngrokEndpoint: configuration.ngrokEndpoint }; + const response = { ngrokEnabled: getOption("server use ngrok"), ngrokEndpoint: standardPaths.ngrokEndpoint }; res.send(response); next(); }); diff --git a/developer/src/server/src/standardPaths.ts b/developer/src/server/src/standardPaths.ts new file mode 100644 index 0000000000..bc84104d9e --- /dev/null +++ b/developer/src/server/src/standardPaths.ts @@ -0,0 +1,33 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Path and URL constants (in some cases calculated) + */ +import { mkdirSync } from 'node:fs'; + +class StandardPaths { + public readonly appDataPath: string; + public readonly cachePath: string; + public readonly cacheStateFilename: string; + public readonly lockFilename: string; + public readonly pidFilename: string; + + /* ngrok Configuration */ + + public ngrokEndpoint: string = ''; + + constructor() { + + this.appDataPath = (process.env.APPDATA || + (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")) + + '/Keyman/Keyman Developer/Server/'; + this.cachePath = this.appDataPath + 'cache/'; + this.cacheStateFilename = this.appDataPath + 'cache.json'; + this.lockFilename = this.appDataPath + 'lock.json'; + this.pidFilename = this.appDataPath + 'pid.json'; + + mkdirSync(this.cachePath, {recursive: true}); + } +}; + +export const standardPaths = new StandardPaths(); diff --git a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas index 904d389a08..37c4b483b4 100644 --- a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas +++ b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas @@ -76,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; diff --git a/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas b/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas index 6beeafe62e..1322010798 100644 --- a/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas +++ b/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas @@ -20,7 +20,6 @@ type const S_Kmc = 'kmc.cmd'; class function KmcPath: string; static; - const S_ServerConfigJson = 'config.json'; class function ServerDataPath: string; static; class function ServerPath: string; static; diff --git a/developer/src/tike/main/KeymanDeveloperOptions.pas b/developer/src/tike/main/KeymanDeveloperOptions.pas index 22109f58ff..09c6afcd9c 100644 --- a/developer/src/tike/main/KeymanDeveloperOptions.pas +++ b/developer/src/tike/main/KeymanDeveloperOptions.pas @@ -81,7 +81,6 @@ type procedure optWriteString(const nm, value: string); procedure optWriteBool(const nm: string; value: Boolean); procedure optWriteInt(const nm: string; value: Integer); - procedure WriteServerConfigurationJson; class function Get_Initial_DefaultProjectPath: string; static; function BackOffAndSaveJson(const Filename: string; const JSON: TJSONObject): Boolean; public @@ -476,8 +475,6 @@ begin finally FreeAndNil(json); end; - - WriteServerConfigurationJson; end; function TKeymanDeveloperOptions.BackOffAndSaveJson(const Filename: string; const JSON: TJSONObject): Boolean; @@ -513,23 +510,6 @@ begin Result := False; end; -procedure TKeymanDeveloperOptions.WriteServerConfigurationJson; -var - o: TJSONObject; -begin - o := TJSONObject.Create; - try - o.AddPair('port', TJSONNumber.Create(FServerDefaultPort)); - o.AddPair('ngrokToken', FServerNgrokToken); - o.AddPair('useNgrok', TJSONBool.Create(FServerUseNgrok)); - o.AddPair('ngrokVisible', TJSONBool.Create(FServerServerShowConsoleWindow)); - ForceDirectories(TKeymanDeveloperPaths.ServerDataPath); - BackOffAndSaveJSON(TKeymanDeveloperPaths.ServerDataPath + TKeymanDeveloperPaths.S_ServerConfigJson, o); - finally - o.Free; - end; -end; - procedure TKeymanDeveloperOptions.optWriteBool(const nm: string; value: Boolean); begin json.AddPair(nm, TJSONBool.Create(value)); diff --git a/developer/src/tike/main/UframeTextEditor.pas b/developer/src/tike/main/UframeTextEditor.pas index de6e293774..b9f5f8ce22 100644 --- a/developer/src/tike/main/UframeTextEditor.pas +++ b/developer/src/tike/main/UframeTextEditor.pas @@ -486,7 +486,12 @@ procedure TframeTextEditor.LoadFileInBrowser(const AData: string); function GenerateNewFilename: string; begin Inc(FInitialFilenameIndex); - Result := '*texteditor*'+IntToStr(FInitialFilenameIndex); + Result := '*texteditor'; + if Owner <> nil then + Result := Result + '*' + Owner.ClassName; + if Parent <> nil then + Result := Result + '*' + Parent.Name; + Result := Result + '*'+IntToStr(FInitialFilenameIndex); end; function EncodeFont(const prefix: string; f: TFont): string; begin diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm index fb286bccc3..e06066a2e1 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm @@ -1,119 +1,127 @@ -inherited frmCloneKeymanCloudProjectParameters: TfrmCloneKeymanCloudProjectParameters - BorderIcons = [biSystemMenu, biMaximize] - Caption = 'Clone Project from Keyman Cloud' - ClientHeight = 506 - ClientWidth = 840 - Position = poScreenCenter - ExplicitWidth = 856 - ExplicitHeight = 545 - PixelsPerInch = 96 - TextHeight = 13 - object cmdOK: TButton - Left = 680 - Top = 473 - Width = 73 - Height = 25 - Anchors = [akRight, akBottom] - Caption = 'OK' - Default = True - TabOrder = 2 - OnClick = cmdOKClick - end - object cmdCancel: TButton - Left = 759 - Top = 473 - Width = 73 - Height = 25 - Anchors = [akRight, akBottom] - Cancel = True - Caption = 'Cancel' - ModalResult = 2 - TabOrder = 3 - end - object panWebHost: TPanel - Left = 0 - Top = 0 - Width = 842 - Height = 334 - Anchors = [akLeft, akTop, akRight, akBottom] - BevelOuter = bvNone - TabOrder = 0 - end - object gbNewProjectDetails: TGroupBox - Left = 8 - Top = 340 - Width = 823 - Height = 129 - Anchors = [akLeft, akRight, akBottom] - Caption = 'New Project Details' - TabOrder = 1 - object lblFileName: TLabel - Left = 16 - Top = 24 - Width = 76 - Height = 13 - Caption = '&New project ID:' - FocusControl = editKeyboardID - end - object lblProjectFilename: TLabel - Left = 16 - Top = 102 - Width = 101 - Height = 13 - Caption = 'New project &filename' - FocusControl = editProjectFilename - end - object lblPath: TLabel - Left = 16 - Top = 75 - Width = 83 - Height = 13 - Caption = 'Destination &path:' - FocusControl = editPath - end - object editKeyboardID: TEdit - Left = 148 - Top = 21 - Width = 205 - Height = 21 - TabOrder = 0 - OnChange = editKeyboardIDChange - end - object editProjectFilename: TEdit - Left = 148 - Top = 99 - Width = 669 - Height = 21 - TabStop = False - ParentColor = True - ReadOnly = True - TabOrder = 4 - OnChange = editKeyboardIDChange - end - object cmdBrowse: TButton - Left = 744 - Top = 72 - Width = 73 - Height = 21 - Caption = '&Browse...' - TabOrder = 3 - OnClick = cmdBrowseClick - end - object editPath: TEdit - Left = 148 - Top = 72 - Width = 590 - Height = 21 - TabOrder = 2 - OnChange = editPathChange - end - object chkRelocateExternal: TCheckBox - Left = 148 - Top = 48 - Width = 257 - Height = 17 - Caption = 'Relocate &external files into new project folder' - TabOrder = 1 - end - end -end +inherited frmCloneKeymanCloudProjectParameters: TfrmCloneKeymanCloudProjectParameters + BorderIcons = [biSystemMenu, biMaximize] + Caption = 'Clone Project from Keyman Cloud' + ClientHeight = 506 + ClientWidth = 840 + Position = poScreenCenter + ExplicitWidth = 856 + ExplicitHeight = 545 + PixelsPerInch = 96 + TextHeight = 13 + object lblMessage: TLabel + Left = 8 + Top = 478 + Width = 314 + Height = 13 + Caption = 'The keyboard %0:s has no source available. It cannot be cloned.' + FocusControl = editPath + end + object cmdOK: TButton + Left = 680 + Top = 473 + Width = 73 + Height = 25 + Anchors = [akRight, akBottom] + Caption = 'OK' + Default = True + TabOrder = 2 + OnClick = cmdOKClick + end + object cmdCancel: TButton + Left = 759 + Top = 473 + Width = 73 + Height = 25 + Anchors = [akRight, akBottom] + Cancel = True + Caption = 'Cancel' + ModalResult = 2 + TabOrder = 3 + end + object panWebHost: TPanel + Left = 0 + Top = 0 + Width = 842 + Height = 334 + Anchors = [akLeft, akTop, akRight, akBottom] + BevelOuter = bvNone + TabOrder = 0 + end + object gbNewProjectDetails: TGroupBox + Left = 8 + Top = 340 + Width = 823 + Height = 129 + Anchors = [akLeft, akRight, akBottom] + Caption = 'New Project Details' + TabOrder = 1 + object lblFileName: TLabel + Left = 16 + Top = 24 + Width = 76 + Height = 13 + Caption = '&New project ID:' + FocusControl = editKeyboardID + end + object lblProjectFilename: TLabel + Left = 16 + Top = 102 + Width = 101 + Height = 13 + Caption = 'New project &filename' + FocusControl = editProjectFilename + end + object lblPath: TLabel + Left = 16 + Top = 75 + Width = 83 + Height = 13 + Caption = 'Destination &path:' + FocusControl = editPath + end + object editKeyboardID: TEdit + Left = 148 + Top = 21 + Width = 205 + Height = 21 + TabOrder = 0 + OnChange = editKeyboardIDChange + end + object editProjectFilename: TEdit + Left = 148 + Top = 99 + Width = 669 + Height = 21 + TabStop = False + ParentColor = True + ReadOnly = True + TabOrder = 4 + OnChange = editKeyboardIDChange + end + object cmdBrowse: TButton + Left = 744 + Top = 72 + Width = 73 + Height = 21 + Caption = '&Browse...' + TabOrder = 3 + OnClick = cmdBrowseClick + end + object editPath: TEdit + Left = 148 + Top = 72 + Width = 590 + Height = 21 + TabOrder = 2 + OnChange = editPathChange + end + object chkRelocateExternal: TCheckBox + Left = 148 + Top = 48 + Width = 257 + Height = 17 + Caption = 'Relocate &external files into new project folder' + TabOrder = 1 + end + end +end diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas index c3f09a6406..66f321befb 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas @@ -46,6 +46,7 @@ type cmdBrowse: TButton; editPath: TEdit; chkRelocateExternal: TCheckBox; + lblMessage: TLabel; procedure cmdOKClick(Sender: TObject); procedure editSourceProjectFilenameChange(Sender: TObject); procedure FormCreate(Sender: TObject); @@ -57,6 +58,7 @@ type cef: TframeCEFHost; dlgBrowse: TBrowse4Folder; FKeymanID: string; + FSourceAvailable: Boolean; frmDownloadProgress: TfrmDownloadProgress; function GetBasePath: string; function GetKeyboardID: string; @@ -64,6 +66,7 @@ type procedure EnableControls; procedure SetKeyboardID(const Value: string); procedure UpdateProjectFilename; + procedure UpdateMessage; function GetProjectFilename: string; function GetSourceProjectFilename: string; function GetRelocateExternal: Boolean; @@ -71,6 +74,7 @@ type procedure cefLoadEnd(Sender: TObject); procedure DownloadCallback(Owner: TfrmDownloadProgress; var Result: Boolean); procedure DownloadWrapperCallback(var Cancelled: Boolean); + function IsKeyboardSourceAvailable(const id: string): Boolean; protected function GetHelpTopic: string; override; public @@ -86,9 +90,11 @@ function ShowCloneKeymanCloudProjectParameters(Owner: TComponent): Boolean; implementation uses + System.JSON, System.Net.UrlClient, Vcl.ComCtrls, + HttpUploader, KeymanDeveloperOptions, Keyman.Developer.System.KmcWrapper, Keyman.Developer.System.HelpTopics, @@ -182,6 +188,7 @@ begin dlgBrowse.Root := Desktop; dlgBrowse.Title := 'Select folder to save project to'; + UpdateMessage; EnableControls; end; @@ -201,9 +208,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 := diff --git a/developer/src/tike/xml/layoutbuilder/layer-controls.js b/developer/src/tike/xml/layoutbuilder/layer-controls.js index 8fa04d369e..0f1797d413 100644 --- a/developer/src/tike/xml/layoutbuilder/layer-controls.js +++ b/developer/src/tike/xml/layoutbuilder/layer-controls.js @@ -31,7 +31,8 @@ $(function() { $('#btnDelLayer').click(function () { if ($('#selLayer option').length == 1) return; builder.saveUndo(); - KVKL[builder.lastPlatform].layer.splice(builder.lastLayerIndex, 1); + const deletedLayer = KVKL[builder.lastPlatform].layer.splice(builder.lastLayerIndex, 1)[0]; + updateNextLayerReferences(KVKL[builder.lastPlatform], deletedLayer.id, ''); builder.selectPlatform(); builder.generate(false,true); }); @@ -45,29 +46,8 @@ $(function() { // Layer dialogs // - function submitLayerProperties() { - var newLayerName = $('#layerName').val(); - if (!newLayerName.match(/^[a-zA-Z0-9_-]+$/)) { - alert('Layer name must contain only alphanumerics, underscore and hyphen.'); - return false; - } - for(var i = 0; i < KVKL[builder.lastPlatform].layer.length; i++) { - if(i != builder.lastLayerIndex && KVKL[builder.lastPlatform].layer[i].id == newLayerName) { - alert('Layer name must not already be in use for the current platform.'); - return false; - } - } - - builder.saveUndo(); - builder.generate(); - - var platform = KVKL[builder.lastPlatform]; - var oldLayerName = platform.layer[builder.lastLayerIndex].id; - - let fixup = function(key) { - if (key.layer == oldLayerName) { - key.layer = newLayerName; - } + function updateNextLayerReferences(platform, oldLayerName, newLayerName) { + const fixup = function(key) { if (key.nextlayer == oldLayerName) { key.nextlayer = newLayerName; } @@ -94,6 +74,28 @@ $(function() { }); } }); + } + + function submitLayerProperties() { + const newLayerName = $('#layerName').val(); + if (!newLayerName.match(/^[a-zA-Z0-9_-]+$/)) { + alert('Layer name must contain only alphanumerics, underscore and hyphen.'); + return false; + } + for(let i = 0; i < KVKL[builder.lastPlatform].layer.length; i++) { + if(i != builder.lastLayerIndex && KVKL[builder.lastPlatform].layer[i].id == newLayerName) { + alert('Layer name must not already be in use for the current platform.'); + return false; + } + } + + builder.saveUndo(); + builder.generate(); + + const platform = KVKL[builder.lastPlatform]; + const oldLayerName = platform.layer[builder.lastLayerIndex].id; + + updateNextLayerReferences(platform, oldLayerName, newLayerName); platform.layer[builder.lastLayerIndex].id = newLayerName; builder.prepareLayers(); diff --git a/ios/.gitignore b/ios/.gitignore index 31d4700aec..556562d4b6 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -13,6 +13,7 @@ samples/KMSample2/KeymanEngine.xcframework samples/KMSample2/build engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanios.js engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanweb-osk.ttf +engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/globe-hint.css engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/kmwosk.css engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keyman.js.map engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanweb-webview.js diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 94dea1d5c7..2365c073a5 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ CE8B0BBD248734240045EB2E /* KeymanPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */; }; CE8B0BBF248764ED0045EB2E /* KMPResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBE248764ED0045EB2E /* KMPResource.swift */; }; CE8B5BB22491DA540075CCB0 /* 13.0 Cloud to Package Migration.bundle in Resources */ = {isa = PBXBuildFile; fileRef = CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */; }; + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */; }; CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A079DD1223194B100581263 /* FileManagementTests.swift */; }; CE8EDEB323F53F96009E1FF6 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */; }; CE969BE8251AD8B500376D6A /* PackageWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */; }; @@ -459,6 +460,7 @@ CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymanPackageTests.swift; sourceTree = ""; }; CE8B0BBE248764ED0045EB2E /* KMPResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPResource.swift; sourceTree = ""; }; CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "13.0 Cloud to Package Migration.bundle"; sourceTree = ""; }; + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSchemeHandler.swift; sourceTree = ""; }; CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageWebViewController.swift; sourceTree = ""; }; CE96E42C24D1229A005B8E5A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -804,6 +806,7 @@ CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */, C0EF3E7A1F95B65300CE9BD4 /* KeymanWebDelegate.swift */, C0C16A881FA8146300F090BA /* KeymanWebViewController.swift */, + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */, C0A5FF361F6682EB00BE740C /* PopoverView.swift */, ); path = Keyboard; @@ -1522,6 +1525,7 @@ 9A079DCA222E050E00581263 /* LexicalModelKeymanPackage.swift in Sources */, 9A60764422893A4E003BCFBA /* SettingsViewController.swift in Sources */, C06085B41F9485E40057E5B9 /* UIButton+Helpers.swift in Sources */, + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */, C0959CD41F99C44E00B616BC /* Constants.swift in Sources */, C0452BAD1F9F21270064431A /* Keyboard.swift in Sources */, 29B30C232B564F9900C342A4 /* KeymanEngineLogger.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 4bd5375bc9..02560e6929 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -38,7 +38,8 @@ class KeymanWebViewController: UIViewController { let storage: Storage weak var delegate: KeymanWebDelegate? private var useSpecialFont = false - private var userContentController = WKUserContentController() + private let userContentController = WKUserContentController() + private let schemeHandler: WebViewSchemeHandler private let keymanWebViewName: String = "keyman" // Views @@ -69,8 +70,9 @@ class KeymanWebViewController: UIViewController { init(storage: Storage) { self.storage = storage + self.schemeHandler = WebViewSchemeHandler(storage: storage) + super.init(nibName: nil, bundle: nil) - _ = view } @@ -122,6 +124,7 @@ class KeymanWebViewController: UIViewController { config.preferences = prefs config.suppressesIncrementalRendering = false config.userContentController = self.userContentController + config.setURLSchemeHandler(schemeHandler, forURLScheme: schemeHandler.scheme) webView = KeymanWebView(frame: CGRect(origin: .zero, size: keyboardSize), configuration: config) webView!.isOpaque = false @@ -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() diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift new file mode 100644 index 0000000000..0b09f79732 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -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! + } +} diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift index 6775edb660..fd8b98e8a8 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift @@ -245,6 +245,10 @@ extension Storage { resourceName: "kmwosk.css", dstDir: baseDir, excludeFromBackup: true) + try Storage.copy(from: bundle, + resourceName: "globe-hint.css", + dstDir: baseDir, + excludeFromBackup: true) try Storage.copy(from: bundle, resourceName: "keymanweb-osk.ttf", dstDir: baseDir, diff --git a/ios/engine/build.sh b/ios/engine/build.sh index 9b98249b9b..f341e13983 100755 --- a/ios/engine/build.sh +++ b/ios/engine/build.sh @@ -102,7 +102,8 @@ function update_bundle ( ) { KMW_PRODUCT="$KEYMAN_ROOT/web/build/app/webview/$CONFIG" KMW_RESOURCES="$KEYMAN_ROOT/web/build/app/resources" - #Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + # Copy 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" diff --git a/oem/firstvoices/android/app/build.gradle b/oem/firstvoices/android/app/build.gradle index 9f42f94085..aa5e65941d 100644 --- a/oem/firstvoices/android/app/build.gradle +++ b/oem/firstvoices/android/app/build.gradle @@ -137,6 +137,7 @@ dependencies { implementation files('libs/keyman-engine.aar') implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } apply plugin: 'com.android.application' diff --git a/resources/build/version/src/fixupHistory.ts b/resources/build/version/src/fixupHistory.ts index 4f34c0c13d..ff98088571 100644 --- a/resources/build/version/src/fixupHistory.ts +++ b/resources/build/version/src/fixupHistory.ts @@ -7,13 +7,9 @@ type GitHub = ReturnType; import { readFileSync, writeFileSync } from 'node:fs'; import { gt } from 'semver'; -import { reportHistory } from './reportHistory.js'; +import { reportHistory, PRInformation } from './reportHistory.js'; import { spawnChild } from './util/spawnAwait.js'; -interface PRInformation { - title: string; - number: number; -} // ------------------------------------------------------------------------------------ // splitPullsIntoHistory diff --git a/resources/build/version/src/reportHistory.ts b/resources/build/version/src/reportHistory.ts index 91bfb53d33..8fbd8519b9 100644 --- a/resources/build/version/src/reportHistory.ts +++ b/resources/build/version/src/reportHistory.ts @@ -33,7 +33,7 @@ const getPullRequestInformation = async ( return commit_id; }; -interface PRInformation { +export interface PRInformation { title: string; number: number; version?: string; diff --git a/resources/docker-images/run.sh b/resources/docker-images/run.sh index 534e84eebf..a76d2bcb0b 100755 --- a/resources/docker-images/run.sh +++ b/resources/docker-images/run.sh @@ -44,6 +44,9 @@ else fi run_android() { + if [[ -n ${RELEASE_OEM:-} ]] && [[ -n ${RELEASE_OEM_FIRSTVOICES:-} ]]; then + DOCKER_RUN_ARGS+=(-e "RELEASE_OEM=${RELEASE_OEM:-}" -e "RELEASE_OEM_FIRSTVOICES=${RELEASE_OEM_FIRSTVOICES:-}") + fi docker_wrapper run "${DOCKER_RUN_ARGS[@]}" -i --rm -v "${KEYMAN_ROOT}":/home/build/build \ -v "${KEYMAN_ROOT}/core/build/docker-core/${build_dir}":/home/build/build/core/build \ "${registry_slash}keymanapp/keyman-android-ci:${image_version}" \ diff --git a/web/build.sh b/web/build.sh index ca253ab82c..143a3b8aa9 100755 --- a/web/build.sh +++ b/web/build.sh @@ -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() { diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index c2a8b472b0..76ff91cf98 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -19,8 +19,8 @@

Automatic Mode Example

-

-

+

+

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

Manual Control - Custom Interface

-

Keyboard:

+

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

KeymanWeb

-

-

+

+

Back to Document diff --git a/web/docs/engine/guide/examples/full-manual-control.md b/web/docs/engine/guide/examples/full-manual-control.md index 39be4d34b3..98d4236f4d 100644 --- a/web/docs/engine/guide/examples/full-manual-control.md +++ b/web/docs/engine/guide/examples/full-manual-control.md @@ -28,15 +28,15 @@ Include the following script in the HEAD of your page: } document.f.multilingual.focus(); - keyman.setActiveKeyboard('', ''); + await keyman.setActiveKeyboard('', ''); }); /* KWControlChange: Called when user selects an item in the KWControl SELECT */ - function KWControlChange() { + async function KWControlChange() { /* Select the keyboard in KeymanWeb */ var name = KWControl.value.substr(0, KWControl.value.indexOf("$$")); - var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$"+2)); - keyman.setActiveKeyboard(name, languageCode); + var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$") + 2); + await keyman.setActiveKeyboard(name, languageCode); /* Focus onto the multilingual field in the form */ document.f.multilingual.focus(); } diff --git a/web/docs/engine/guide/examples/manual-control.md b/web/docs/engine/guide/examples/manual-control.md index d67db736e9..8675949936 100644 --- a/web/docs/engine/guide/examples/manual-control.md +++ b/web/docs/engine/guide/examples/manual-control.md @@ -2,7 +2,10 @@ title: Manual Mode Example --- -In this example, the web page designer specifies when KeymanWeb's on-screen keyboard may be displayed on non-mobile devices. They have also specified that the LaoKeys keyboard should be activated by default. This example continues to use the KeymanWeb default interface. Please click [this link](__manual-control.html) to open the test page. +In this example, the web page designer specifies when KeymanWeb's on-screen keyboard may be +displayed on non-mobile devices. They have also specified that the LaoKeys keyboard should be +activated by default. This example continues to use the KeymanWeb default interface. Please click +[this link](__manual-control.html) to open the test page. ## Code Walkthrough @@ -17,7 +20,7 @@ Include the following script in the HEAD of your page: languages:{id:'lo',name:'Lao'}, filename: "./js/laokeys.js" }); - keyman.setActiveKeyboard('laokeys'); + await keyman.setActiveKeyboard('laokeys'); keyman.osk.hide(); }); diff --git a/web/src/engine/src/interfaces/pathConfiguration.ts b/web/src/engine/src/interfaces/pathConfiguration.ts index 090dc7d4c4..4ab8432bcd 100644 --- a/web/src/engine/src/interfaces/pathConfiguration.ts +++ b/web/src/engine/src/interfaces/pathConfiguration.ts @@ -21,24 +21,12 @@ export class PathConfiguration implements OSKResourcePathConfiguration { private _fonts: string; readonly protocol: string; - /* - * Pre-modularization code corresponding to `sourcePath`: - ``` - // Determine path and protocol of executing script, setting them as - // construction defaults. - // - // This can only be done during load when the active script will be the - // last script loaded. Otherwise the script must be identified by name. - - var scripts = document.getElementsByTagName('script'); - var ss = scripts[scripts.length-1].src; - var sPath = ss.substr(0,ss.lastIndexOf('/')+1); - ``` - */ constructor(pathSpec: Required, sourcePath: string) { + const sourceURL = new URL(sourcePath); + sourcePath = addDelimiter(sourcePath); this.sourcePath = sourcePath; - this.protocol = sourcePath.replace(/(.{3,5}:)(.*)/,'$1'); + this.protocol = sourceURL.protocol; this.updateFromOptions(pathSpec); } @@ -75,8 +63,16 @@ export class PathConfiguration implements OSKResourcePathConfiguration { p = addDelimiter(p); - // Absolute - if((p.replace(/^(http)s?:.*/,'$1') == 'http') || (p.replace(/^(file):.*/,'$1') == 'file')) { + // Absolute - with protocol specified + const protocolList = [ + 'http:', + 'https:', + 'file:', + // If using a custom origin (say, hosted in an iOS WebView via WKURLSchemeHandler) + this.protocol + ]; + + if(protocolList.find((protocol) => p.startsWith(protocol))) { return p; } diff --git a/web/src/engine/src/main/keymanEngineBase.ts b/web/src/engine/src/main/keymanEngineBase.ts index 79a7f599de..643e5aa072 100644 --- a/web/src/engine/src/main/keymanEngineBase.ts +++ b/web/src/engine/src/main/keymanEngineBase.ts @@ -3,7 +3,7 @@ import { ProcessorInitOptions } from 'keyman/engine/js-processor'; import { DOMKeyboardLoader } from "keyman/engine/keyboard"; import { WorkerFactory } from "@keymanapp/lexical-model-layer/web" import { InputProcessor } from './headless/inputProcessor.js'; -import { OSKView, KeyboardData } from "keyman/engine/osk"; +import { OSKView } from "keyman/engine/osk"; import { KeyboardRequisitioner, ModelCache, toUnprefixedKeyboardId, DOMCloudRequester } from "keyman/engine/keyboard-storage"; import { ModelSpec, PredictionContext } from "keyman/engine/interfaces"; @@ -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); diff --git a/web/src/engine/src/osk/views/oskView.ts b/web/src/engine/src/osk/views/oskView.ts index 962d943557..ab8ab9e7b3 100644 --- a/web/src/engine/src/osk/views/oskView.ts +++ b/web/src/engine/src/osk/views/oskView.ts @@ -130,7 +130,7 @@ export function getResourcePath(config: ViewConfiguration) { if(config.isEmbedded) { resourcePathExt = ''; } - return `${config.pathConfig.resources}/${resourcePathExt}` + return `${config.pathConfig.resources}${resourcePathExt}` } export abstract class OSKView @@ -267,6 +267,7 @@ export abstract class OSKView for(const sheetFile of OSKView.STYLESHEET_FILES) { const sheetHref = `${resourcePath}${sheetFile}`; + this.uiStyleSheetManager.linkExternalSheet(sheetHref); } @@ -856,7 +857,7 @@ export abstract class OSKView isEmbedded: this.config.isEmbedded, specialFont: { family: 'SpecialOSK', - files: [`${resourcePath}/keymanweb-osk.ttf`], + files: [`${resourcePath}keymanweb-osk.ttf`], path: '' // Not actually used. }, gestureParams: this.config.gestureParams diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index e0ca4a46a8..053b26f5c1 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -40,7 +40,7 @@ import { KeyTip } from './keytip.interface.js'; import { OSKKey } from './keyboard-layout/oskKey.js'; import { OSKLayer, LayerLayoutParams } from './keyboard-layout/oskLayer.js'; import { OSKLayerGroup } from './keyboard-layout/oskLayerGroup.js'; -import { OSKView } from './views/oskView.js'; +import { getResourcePath, OSKView } from './views/oskView.js'; import { ParsedLengthStyle } from './lengthStyle.js'; import { defaultFontSize } from './fontSizeUtils.js'; import { PhoneKeyTip } from './input/gestures/browser/phoneKeytip.js'; @@ -1372,7 +1372,8 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi device: { formFactor?: DeviceSpec.FormFactor, OS?: DeviceSpec.OperatingSystem, - touchable?: boolean + touchable?: boolean, + browser?: DeviceSpec.Browser } = {}; // Device emulation for target documentation. @@ -1380,9 +1381,11 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi if (formFactor != 'desktop') { device.OS = DeviceSpec.OperatingSystem.iOS; device.touchable = true; + device.browser = DeviceSpec.Browser.Safari; } else { device.OS = DeviceSpec.OperatingSystem.Windows; device.touchable = false; + device.browser = DeviceSpec.Browser.Chrome; } const layout = PKbd.layout(formFactor); @@ -1398,7 +1401,11 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi styleSheetManager: null, specialFont: { family: 'SpecialOSK', - files: [`${pathConfig.resources}/osk/keymanweb-osk.ttf`], + files: [`${getResourcePath({ + // Not actually leveraged. + hostDevice: device as DeviceSpec, + pathConfig + })}keymanweb-osk.ttf`], path: '' // Not actually used. } }); diff --git a/web/src/test/auto/e2e/e2eUtils.ts b/web/src/test/auto/e2e/e2eUtils.ts new file mode 100644 index 0000000000..0ef45ffec0 --- /dev/null +++ b/web/src/test/auto/e2e/e2eUtils.ts @@ -0,0 +1,64 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { type Locator, type Page } from "@playwright/test"; + +/** + * Expands the keyboard selection menu and returns the text content of the + * currently selected keyboard. + */ +export async function getSelectedKeyboardMenuText(page: Page): Promise { + const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); + await page.getByRole('img', { name: 'Use Web Keyboard' }).click(); + await watchDog; + return page.evaluate(() => { + const selectedKbd = document.querySelector('#kmwico .selected'); + return selectedKbd?.textContent; + }); +}; + +/** + * Expands the keyboard selection menu and returns the menu items as an array + */ +export async function getAllKeyboardMenuText(page: Page): Promise<(string|undefined)[]> { + const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); + await page.getByRole('img', { name: 'Use Web Keyboard' }).hover(); + await watchDog; + return page.evaluate(() => { + const menuItems = []; + const menuDiv = document.querySelector('#kmwico'); + const kbdList = menuDiv?.lastElementChild; + for (let i = 0; i < (kbdList ? kbdList.children.length : 0); i++) { + const item = kbdList?.children[i]; + menuItems.push(item?.textContent); + } + return menuItems; + }); +} + +/** + * Loads the specified URL and waits for the page load event. + */ +export async function loadPage(page: Page, url: string): Promise { + const loadPromise = page.waitForEvent('load'); + await page.goto(url); + return loadPromise; +} + +/** + * Clicks the specified field and waits for the OSK to be shown, returning a + * locator for the OSK title bar. + */ +export async function clickFieldAndWaitForOSK(page: Page, fieldLocator: Locator): Promise { + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await fieldLocator.click(); + await keyboardchangePromise; + return page.locator('#keymanweb_title_bar'); +} diff --git a/web/src/test/auto/e2e/guide-examples.tests.ts b/web/src/test/auto/e2e/guide-examples.tests.ts new file mode 100644 index 0000000000..19f2fb4bf8 --- /dev/null +++ b/web/src/test/auto/e2e/guide-examples.tests.ts @@ -0,0 +1,256 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ +import { test, expect, type Page } from '@playwright/test'; +import { clickFieldAndWaitForOSK, getAllKeyboardMenuText, getSelectedKeyboardMenuText, loadPage } from './e2eUtils'; + +async function setTimeoutAndLoadPage(page: Page, url: string): Promise { + test.setTimeout(5000); + await loadPage(page, url); +} + +test.describe('First example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__first-example.html'); + } + + test('Input field shows US keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + const oskTitleBar = await clickFieldAndWaitForOSK(page, page.getByPlaceholder('Hello World')); + + // Verify OSK shows US keyboard + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(oskTitleBar).toContainText('US'); + + await expect(await getSelectedKeyboardMenuText(page)).toBe('English - US'); + }); + + test('Keyman menu has expected keyboards', async ({ page }) => { + // Setup + await beforeEach(page); + await clickFieldAndWaitForOSK(page, page.getByPlaceholder('Hello World')); + + // Verify OSK menu has expected entries + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + await expect(await getAllKeyboardMenuText(page)).toEqual(['(System keyboard)', 'English - US', 'Thai - Thai Kedmanee Basic']) + }); +}); + +test.describe('Auto-control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__auto-control.html'); + } + + test('Input field shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('multilingual' ).click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + }); + + test('Textarea shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('textarea').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + }); +}); + +test.describe('Control-by-control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__control-by-control.html'); + } + + test('address field does not have KeymanWeb enabled', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = address').click(); + + // Verify OSK is not shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeFalsy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).not.toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + }); + + // TODO: #16080 + test.skip('subject field does not show keyboard and defaults to system keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = subject').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + + await expect(await getSelectedKeyboardMenuText(page)).toBe('(System keyboard)'); + }); + + test('message body field shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = text').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + + // Verify Lao (Phonetic) keyboard is active + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + // Verify "Lao - Lao (Phonetic)" is selected (bold) in the menu + await expect(await getSelectedKeyboardMenuText(page)).toBe('Lao - Lao (Phonetic)'); + }); +}); + +test.describe('Full manual control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__full-manual-control.html'); + } + + test('Shows English and no OSK after loading page', async ({ page }) => { + // Setup + await beforeEach(page); + + // Verify 'English' selected (which has the value '') and no OSK showing + await expect(page.getByLabel('Keyboard')).toHaveValue(''); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Selecting English keyboard shows no OSK', async ({ page }) => { + // Setup + await beforeEach(page); + // first switch to Hebrew + let keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Hebrew'); + await keyboardchangePromise; + + // then back to English + keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('English'); + await keyboardchangePromise; + + // Verify no OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Selecting Devanagari keyboard shows Devanagari OSK', async ({ page }) => { + // Setup + await beforeEach(page); + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Devanagari (INSCRIPT)'); + await keyboardchangePromise; + + // Verify Devanagari OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Devanagari (INSCRIPT)'); + }); + + test('Selecting Hebrew shows Hebrew OSK', async ({ page }) => { + // Setup + await beforeEach(page); + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Hebrew'); + await keyboardchangePromise; + + // Verify Hebrew OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Hebrew'); + }); +}); + +test.describe('Manual control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__manual-control.html'); + } + + test('Does not show OSK after loading', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('multilingual').click(); + + // Verify no OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).not.toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Shows Lao OSK after clicking button', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByAltText('KeymanWeb').click(); + await page.getByTestId('multilingual').click(); + + // Verify Lao OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao'); + }); + + test('Hides Lao OSK after clicking button', async ({ page }) => { + // Setup + await beforeEach(page); + + // click button + await page.getByAltText('KeymanWeb').click(); + + // Verify Lao OSK showing + await page.getByTestId('multilingual').click(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao'); + + // Click button again to hide OSK + await page.getByAltText('KeymanWeb').click(); + + // Verify Lao OSK not showing + await page.getByTestId('multilingual').click(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).not.toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + + }); +}); + diff --git a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas index dc41ae59bc..0a28e1b828 100644 --- a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas +++ b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas @@ -158,6 +158,7 @@ begin try http.Fields.Add('version', ansistring(CKeymanVersionInfo.Version)); http.Fields.Add('tier', ansistring(CKeymanVersionInfo.Tier)); + http.Fields.Add('update', '1'); // This is checking for an update if FForce then http.Fields.Add('manual', '1') else