From acca3f0dbee52a6548f334cfe2a402ff87f63c95 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 23 Mar 2023 14:57:47 +0700 Subject: [PATCH] change(web): keyboard activation as part of ContextManager, related odds and ends --- .../src/keyboards/keyboardLoaderBase.ts | 46 ++- .../keyboards/loaders/domKeyboardLoader.ts | 11 +- .../keyboards/loaders/nodeKeyboardLoader.ts | 18 +- .../tests/node/keyboard-loading.js | 26 +- web/src/app/browser/src/contextManager.ts | 80 +++-- web/src/app/browser/src/keymanEngine.ts | 38 --- web/src/app/webview/src/contextManager.ts | 66 ++-- web/src/app/webview/src/keymanEngine.ts | 30 -- web/src/engine/main/src/contextManagerBase.ts | 204 ++++++++++-- web/src/engine/main/src/keymanEngine.ts | 144 +++----- .../namespaced-main/keyboards/kmwkeyboards.ts | 309 ------------------ .../package-cache/src/stubAndKeyboardCache.ts | 2 +- 12 files changed, 401 insertions(+), 573 deletions(-) diff --git a/common/web/keyboard-processor/src/keyboards/keyboardLoaderBase.ts b/common/web/keyboard-processor/src/keyboards/keyboardLoaderBase.ts index 6d3887246a..2595f37db6 100644 --- a/common/web/keyboard-processor/src/keyboards/keyboardLoaderBase.ts +++ b/common/web/keyboard-processor/src/keyboards/keyboardLoaderBase.ts @@ -1,5 +1,6 @@ import Keyboard from "./keyboard.js"; import { KeyboardHarness } from "./keyboardHarness.js"; +import KeyboardProperties from "./keyboardProperties.js"; export default abstract class KeyboardLoaderBase { private _harness: KeyboardHarness; @@ -19,5 +20,48 @@ export default abstract class KeyboardLoaderBase { return promise; } - protected abstract loadKeyboardInternal(uri: string): Promise; + public loadKeyboardFromStub(stub: KeyboardProperties & { filename: string }) { + this.harness.install(); + let promise = this.loadKeyboardInternal(stub.filename, stub.id); + + promise = promise.catch((err: Error) => { + if(err == KeyboardLoaderBase.scriptErrorMessage(stub.filename)) { + // Enhance the error message. + err.message = KeyboardLoaderBase.scriptErrorMessage(stub); + } else if(err == KeyboardLoaderBase.missingErrorMessage(stub.filename)) { + // Same thing here. + err.message = KeyboardLoaderBase.scriptErrorMessage(stub); + } + + throw err; + }) + + return promise; + } + + protected abstract loadKeyboardInternal(uri: string, id?: string): Promise; + + protected static scriptErrorMessage(uri: string); + protected static scriptErrorMessage(metadata: KeyboardProperties & { filename: string }); + protected static scriptErrorMessage(arg: string | (KeyboardProperties & { filename: string })) { + if(typeof arg == "string") { + const uri = arg; + return `Error registering the keyboard script at ${uri}; it may contain an error.` + } else { + const stub = arg; + return `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.` + } + } + + protected static missingErrorMessage(uri: string); + protected static missingErrorMessage(metadata: KeyboardProperties & { filename: string }); + protected static missingErrorMessage(arg: string | (KeyboardProperties & { filename: string })) { + if(typeof arg == "string") { + const uri = arg; + return `Cannot find the keyboard at ${uri}.` + } else { + const stub = arg; + return `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; + } + } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts b/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts index 08dbac38ce..d753d37af9 100644 --- a/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts +++ b/common/web/keyboard-processor/src/keyboards/loaders/domKeyboardLoader.ts @@ -28,7 +28,7 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { this.performCacheBusting = cacheBust || false; } - protected loadKeyboardInternal(uri: string): Promise { + protected loadKeyboardInternal(uri: string, id?: string): Promise { const promise = new ManagedPromise(); if(this.performCacheBusting) { @@ -38,15 +38,20 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { try { const document = this.harness._jsGlobal.document; const script = document.createElement('script'); + if(id) { + script.id = id; + } document.head.appendChild(script); - script.onerror = promise.reject; + script.onerror = () => { + promise.reject(new Error(KeyboardLoaderBase.missingErrorMessage(uri))); + } script.onload = () => { if(this.harness.loadedKeyboard) { const keyboard = this.harness.loadedKeyboard; this.harness.loadedKeyboard = null; promise.resolve(keyboard); } else { - promise.reject(); + promise.reject(new Error(KeyboardLoaderBase.scriptErrorMessage(uri))); } } diff --git a/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts b/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts index 6f7e7fa196..2f62274317 100644 --- a/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts +++ b/common/web/keyboard-processor/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -23,15 +23,21 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { } protected loadKeyboardInternal(uri: string): Promise { + // `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI. + if(uri.indexOf('file:///') == 0) { + uri = uri.substring('file:///'.length); + } + + let script; + try { + script = new vm.Script(fs.readFileSync(uri).toString()); + } catch (err) { + return Promise.reject(new Error(KeyboardLoaderBase.missingErrorMessage(uri))); + } try { - // `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI. - if(uri.indexOf('file:///') == 0) { - uri = uri.substring('file:///'.length); - } - const script = new vm.Script(fs.readFileSync(uri).toString()); script.runInContext(this.harness._jsGlobal); } catch (err) { - return Promise.reject(err); + return Promise.reject(new Error(KeyboardLoaderBase.scriptErrorMessage(uri))); } const keyboard = this.harness.loadedKeyboard; diff --git a/common/web/keyboard-processor/tests/node/keyboard-loading.js b/common/web/keyboard-processor/tests/node/keyboard-loading.js index fd29f08a27..1b0ccf1f70 100644 --- a/common/web/keyboard-processor/tests/node/keyboard-loading.js +++ b/common/web/keyboard-processor/tests/node/keyboard-loading.js @@ -4,12 +4,13 @@ import fs from 'fs'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -import { KeyboardHarness, KeyboardInterface, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor'; +import { KeyboardHarness, KeyboardInterface, KeyboardLoaderBase, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor'; import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader'; describe('Headless keyboard loading', function() { const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); + const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); // Common test suite setup. let device = { @@ -101,5 +102,28 @@ describe('Headless keyboard loading', function() { assert.equal(khmer_keyboard.id, "Keyboard_khmer_angkor"); }); + + it('throws distinct errors', async function() { + const invalidPath = 'totally_invalid_path.js'; + + let harness = new KeyboardInterface({}, MinimalKeymanGlobal); + let keyboardLoader = new NodeKeyboardLoader(harness); + let missingError; + try { + await keyboardLoader.loadKeyboardFromPath(invalidPath); + } catch (err) { + missingError = err; + assert.equal(missingError.message, KeyboardLoaderBase.missingErrorMessage(invalidPath)); + } + + let scriptLoadError; + try { + await keyboardLoader.loadKeyboardFromPath(nonKeyboardPath); + } catch (err) { + scriptLoadError = err; + } + + assert.notEqual(scriptLoadError.message, KeyboardLoaderBase.scriptErrorMessage(nonKeyboardPath)); + }); }) }); \ No newline at end of file diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts index cc84328c20..4b4690f063 100644 --- a/web/src/app/browser/src/contextManager.ts +++ b/web/src/app/browser/src/contextManager.ts @@ -1,16 +1,31 @@ import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor'; import { type KeyboardStub } from 'keyman/engine/package-cache'; +import { CookieSerializer } from 'keyman/engine/dom-utils'; import { ContextManagerBase, type KeyboardInterface } from 'keyman/engine/main'; import { BrowserConfiguration } from './configuration.js'; +interface KeyboardCookie { + current: string; +} + export default class ContextManager extends ContextManagerBase { private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub}; private config: BrowserConfiguration; + private cookieManager = new CookieSerializer('KeymanWeb_Keyboard'); initialize(): void { + this.on('keyboardasyncload', (stub, completion) => { + // TODO: app/browser - display the loader UI if configured? + // util.wait('Installing keyboard
' + kbdName); + + completion.then(() => { + // Cancel the loader UI. + }); + }); + // TBD: keyman.domManager.init (the page-integration parts) // CTRL+F: `// Exit initialization here if we're using an embedded code path.` // EVERYTHING after that block will likely go here - DOMManager's role always @@ -27,7 +42,7 @@ export default class ContextManager extends ContextManagerBase { return this._activeKeyboard; } - set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) { + setKeyboardActiveForTarget(kbd: {keyboard: Keyboard, metadata: KeyboardStub}, target: OutputTarget) { throw new Error('Method not implemented.'); // depends on the target // if not set with an "independent keyboard", changes the global. @@ -64,7 +79,7 @@ export default class ContextManager extends ContextManagerBase { * When `null`, such operations will affect the global default; otherwise, such operations * affect only the specified `target`. */ - private get keyboardTarget(): OutputTarget { + protected get keyboardTarget(): OutputTarget { // Remove `&& false` once the inlined section below is implemented. if(this.activeTarget /* has 'independent keyboard mode activated' */ && false) { return this.activeTarget; @@ -73,39 +88,46 @@ export default class ContextManager extends ContextManagerBase { } } - async setTargetActiveKeyboardAsync(kbd: Promise, metadata: KeyboardStub, target: OutputTarget): Promise { - if(!await this.deferredKeyboardActivationValid(kbd, metadata, target)) { - return false; - } else { - let activatingKeyboard = { - keyboard: await kbd, - metadata: metadata - }; - if(target == this.keyboardTarget) { - // TODO: 'beforekeyboardchange' event + public async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise { + saveCookie ||= false; + const originalKeyboardTarget = this.keyboardTarget; + + try { + let result = await super.activateKeyboard(keyboardId, languageCode, saveCookie); + + if(saveCookie && !originalKeyboardTarget) { // if the active target uses global keyboard settings + this.cookieManager.save({current: `${keyboardId}:${languageCode}`}); } - // TODO: set THAT TARGET's active keyboard. May or may not be active! - - if(target == this.keyboardTarget) { - // TODO: 'keyboardchange' event + // Only do these if the active keyboard-target still matches the original keyboard-target; + // otherwise, maintain what's correct for the currently active one. + if(originalKeyboardTarget == this.keyboardTarget) { + // TODO: app/browser - _SetTargDir (within its ContextManager) + // util.addStyleSheet(domManager.setAttachmentFontStyle(kbdStub.KF)); + // uiManager.justActivated = true; // TODO: Resolve without need for the cast. } - /* - * Alternative to the three above TODOs: - * if (same condition met) - * this.activeKeyboard = activatingKeyboard - * else - * // manually set it for that one control; no events. - * - */ + return result; + } catch(err) { + // non-embedded: if keyboard activation failed, deactivate the keyboard. - return true; + // Make sure we don't infinite-recursion should the deactivate somehow fail. + if(this.config.hostDevice.touchable) { + // Fallback behavior - if on a touch device, we need to keep a keyboard visible. + const defaultStub = this.keyboardCache.defaultStub; + await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {}); + } else { + // Fallback behavior - if on a desktop device, the user still has a physical keyboard. + // Just clear out the active keyboard & OSK. + await this.activateKeyboard('', '', false).catch(() => {}); + } + + if((this.config as BrowserConfiguration).shouldAlert) { + // TODO: util.alert error report + } + + throw err; // since the site-dev consumer may want to do their own error-handling. } } - - async setActiveKeyboardAsync(kbd: Promise, metadata: KeyboardStub): Promise { - return this.setTargetActiveKeyboardAsync(kbd, metadata, this.keyboardTarget); - } } \ No newline at end of file diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 9ee4f0a480..0ddfda07ba 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -1,6 +1,5 @@ import { KeymanEngine as KeymanEngineBase } from 'keyman/engine/main'; import { ProcessorInitOptions } from "@keymanapp/keyboard-processor"; -import { type KeyboardStub } from "keyman/engine/package-cache"; import { BrowserConfiguration } from './configuration.js'; import ContextManager from './contextManager.js'; @@ -18,41 +17,4 @@ export class KeymanEngine extends KeymanEngineBase { - saveCookie ||= false; - - try { - await super.activateKeyboard(keyboardId, languageCode, saveCookie); - - if(saveCookie /* && this.contextManager.activeTarget does not have independent-keyboard mode active */) { - // TODO: persist the newly-activating keyboard's IDs for use as default upon page reload - } - - // TODO: app/browser - _SetTargDir (within its ContextManager) - } catch(err) { - // non-embedded: if keyboard activation failed, deactivate the keyboard. - - // Make sure we don't infinite-recursion should the deactivate somehow fail. - if(this.config.hostDevice.touchable) { - // Fallback behavior - if on a touch device, we need to keep a keyboard visible. - const defaultStub = this.keyboardRequisitioner.cache.defaultStub; - await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {}); - } else { - // Fallback behavior - if on a desktop device, the user still has a physical keyboard. - // Just clear out the active keyboard & OSK. - await this.activateKeyboard('', '', false).catch(() => {}); - } - - if((this.config as BrowserConfiguration).shouldAlert) { - // TODO: util.alert error report - } - - throw err; // since the site-dev consumer may want to do their own error-handling. - } - } - - protected onKeyboardAsyncLoadStart(requestedStub: KeyboardStub) { - // TODO: app/browser - display the loader UI if configured? - } } diff --git a/web/src/app/webview/src/contextManager.ts b/web/src/app/webview/src/contextManager.ts index 6d4e20a3d9..f31582c562 100644 --- a/web/src/app/webview/src/contextManager.ts +++ b/web/src/app/webview/src/contextManager.ts @@ -1,4 +1,4 @@ -import { type Keyboard, Mock } from '@keymanapp/keyboard-processor'; +import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor'; import { KeyboardStub } from 'keyman/engine/package-cache'; import { ContextManagerBase, ContextManagerConfiguration } from 'keyman/engine/main'; import { WebviewConfiguration } from './configuration.js'; @@ -51,42 +51,50 @@ export default class ContextManager extends ContextManagerBase { return this._activeKeyboard; } - set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) { - const priorEntry = this._activeKeyboard; - - // Clone the stub before exposing it... - if(!this.confirmKeyboardChange(new KeyboardStub(kbd.metadata))) { - return; - } + setKeyboardActiveForTarget(kbd: {keyboard: Keyboard, metadata: KeyboardStub}, target: OutputTarget) { + // `target` is irrelevant for `app/webview`, as it'll only ever use 'global' keyboard settings. // Clone the object to prevent accidental by-reference changes. this._activeKeyboard = {...kbd}; + } - if(priorEntry.keyboard != kbd.keyboard || priorEntry.metadata != kbd.metadata) { - this.emit('keyboardchange', kbd); - this.resetContext(); + /** + * Reflects the active 'target' upon which any `set activeKeyboard` operation will take place. + * For app/webview... there's only one target, thus only a "global default" matters. + */ + protected get keyboardTarget(): Mock { + return null; + } + + + public async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise { + try { + return await super.activateKeyboard(keyboardId, languageCode, saveCookie); + } catch(err) { + // Fallback behavior - we're embedded in a touch-device's webview, so we need to keep a keyboard visible. + const defaultStub = this.keyboardCache.defaultStub; + await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {}); + + throw err; // since the consumer may want to do its own error-handling. } } - async setActiveKeyboardAsync(kbd: Promise, metadata: KeyboardStub): Promise { - if(!this.confirmKeyboardChange) { - return false; + protected prepareKeyboardForActivation( + keyboardId: string, + languageCode?: string + ): {keyboard: Promise, metadata: KeyboardStub} { + const originalKeyboard = this.activeKeyboard; + const activatingKeyboard = super.prepareKeyboardForActivation(keyboardId, languageCode); + + // Probably isn't necessary at this point - osk.refreshLayout() exists - but + // it's best to keep it around for now and verify later. + if(originalKeyboard.metadata.id == activatingKeyboard.metadata.id) { + activatingKeyboard.keyboard = activatingKeyboard.keyboard.then((kbd) => { + kbd.refreshLayouts() + return kbd; + }); } - // There is only the one target, so 'default global keyboard' use is fine. - if(!await this.deferredKeyboardActivationValid(kbd, metadata, null)) { - return false; - } else { - const activatingKeyboard = { - keyboard: await kbd, - metadata: metadata - }; - - this.activeKeyboard = activatingKeyboard; - - // The change may silently fail due to `set activeKeyboard`'s `confirmKeyboardChange` call. - return this.activeKeyboard.keyboard == activatingKeyboard.keyboard - && this.activeKeyboard.metadata == activatingKeyboard.metadata; - } + return activatingKeyboard; } } \ No newline at end of file diff --git a/web/src/app/webview/src/keymanEngine.ts b/web/src/app/webview/src/keymanEngine.ts index 3753b0ebfb..3bec676679 100644 --- a/web/src/app/webview/src/keymanEngine.ts +++ b/web/src/app/webview/src/keymanEngine.ts @@ -52,36 +52,6 @@ export class KeymanEngine extends KeymanEngineBase { - saveCookie ||= false; - - try { - await super.activateKeyboard(keyboardId, languageCode, saveCookie); - } catch(err) { - // Fallback behavior - we're embedded in a touch-device's webview, so we need to keep a keyboard visible. - const defaultStub = this.keyboardRequisitioner.cache.defaultStub; - await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {}); - - throw err; // since the consumer may want to do its own error-handling. - } - } - - protected async prepareKeyboardForActivation( - keyboardId: string, - languageCode?: string - ): Promise<{keyboard: Keyboard, metadata: KeyboardStub}> { - const originalKeyboard = this.contextManager.activeKeyboard; - const activatingKeyboard = await super.prepareKeyboardForActivation(keyboardId, languageCode); - - // Probably isn't necessary at this point - osk.refreshLayout() exists - but - // it's best to keep it around for now and verify later. - if(originalKeyboard.keyboard == activatingKeyboard.keyboard) { - activatingKeyboard.keyboard.refreshLayouts(); - } - - return activatingKeyboard; - } - // Functions that the old 'app/webview' equivalent had always provided to the WebView /** diff --git a/web/src/engine/main/src/contextManagerBase.ts b/web/src/engine/main/src/contextManagerBase.ts index f968a62b4e..215ace507e 100644 --- a/web/src/engine/main/src/contextManagerBase.ts +++ b/web/src/engine/main/src/contextManagerBase.ts @@ -1,12 +1,13 @@ import EventEmitter from 'eventemitter3'; -import { type Keyboard, type KeyboardInterface, type OutputTarget } from '@keymanapp/keyboard-processor'; -import { type KeyboardStub } from 'keyman/engine/package-cache'; +import { DeviceSpec, ManagedPromise, type Keyboard, type KeyboardInterface, type OutputTarget } from '@keymanapp/keyboard-processor'; +import { StubAndKeyboardCache, type KeyboardStub } from 'keyman/engine/package-cache'; import { PredictionContext } from '@keymanapp/input-processor'; interface EventMap { // target, then keyboard. 'targetchange': (target: OutputTarget) => boolean; 'beforekeyboardchange': (metadata: KeyboardStub, abortChange: () => void) => void; + 'keyboardasyncload': (metadata: KeyboardStub, onload: Promise) => void; 'keyboardchange': (kbd: {keyboard: Keyboard, metadata: KeyboardStub}) => void; } @@ -18,13 +19,19 @@ export interface ContextManagerConfiguration { * * Does not reset option-stores, variable-stores, etc. */ - readonly resetKeyState: (outputTarget?: OutputTarget) => void; + readonly resetContext: (outputTarget?: OutputTarget) => void; /** * A predictive-state management object that interfaces the predictive-text banner * with the active context. */ readonly predictionContext: PredictionContext; + + /** + * The stub & keyboard curation cache holding preloaded keyboards and metadata useable + * to load those not yet loaded. + */ + readonly keyboardCache: StubAndKeyboardCache; } interface PendingActivation { @@ -34,12 +41,16 @@ interface PendingActivation { } export abstract class ContextManagerBase extends EventEmitter { + public static readonly TIMEOUT_THRESHOLD = 10000; + abstract initialize(): void; abstract get activeTarget(): OutputTarget; private _predictionContext: PredictionContext; - private _resetKeyState: (outputTarget?: OutputTarget) => void; + protected keyboardCache: StubAndKeyboardCache; + private _resetContext: (outputTarget?: OutputTarget) => void; + private _hostDevice: DeviceSpec; private pendingActivations: PendingActivation[] = []; @@ -47,18 +58,15 @@ export abstract class ContextManagerBase extends EventEmitter { return this._predictionContext; } - protected get resetKeyState(): (outputTarget?: OutputTarget) => void { - return this._resetKeyState; - } - constructor() { super(); } configure(config: ContextManagerConfiguration) { // TODO: Set in followup configuration method. Part of initialization? - this._resetKeyState = config.resetKeyState; + this._resetContext = config.resetContext; this._predictionContext = config.predictionContext; + this.keyboardCache = config.keyboardCache; } insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) { @@ -82,16 +90,20 @@ export abstract class ContextManagerBase extends EventEmitter { } resetContext() { - this._resetKeyState(this.activeTarget); + this._resetContext(this.activeTarget); this.predictionContext.resetContext(); } abstract get activeKeyboard(): {keyboard: Keyboard, metadata: KeyboardStub}; + protected abstract get keyboardTarget(): OutputTarget; - // TODO: should `activateKeyboard` (on KeymanEngine) be relocated to within here? - // It seems to make sense, and was originally a goal. - abstract set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}); - abstract setActiveKeyboardAsync(kbd: Promise, metadata: KeyboardStub): Promise; + /** + * Ensures that newly activated keyboards are set correctly within managed context, possibly + * against inactive output targets. + * @param kbd + * @param target + */ + protected abstract setKeyboardActiveForTarget(kbd: {keyboard: Keyboard, metadata: KeyboardStub}, target: OutputTarget); /** * Checks the pending keyboard-activation array for an entry corresponding to the specified @@ -100,7 +112,7 @@ export abstract class ContextManagerBase extends EventEmitter { * May be `null`, which corresponds to the global default Keyboard. * @returns `true` if pending activation is still valid, `false` otherwise. */ - private findAndPopActivation(target: OutputTarget): boolean { + private findAndPopActivation(target: OutputTarget): PendingActivation { // Array.findIndex requires Chrome 45+. :( let activationIndex; for(activationIndex = 0; activationIndex < this.pendingActivations.length; activationIndex++) { @@ -110,11 +122,10 @@ export abstract class ContextManagerBase extends EventEmitter { } if(activationIndex == this.pendingActivations.length) { - return false; + return null; } - this.pendingActivations.splice(activationIndex, 1); - return true; + return this.pendingActivations.splice(activationIndex, 1)[0]; } protected confirmKeyboardChange(metadata: KeyboardStub): boolean { @@ -127,7 +138,11 @@ export abstract class ContextManagerBase extends EventEmitter { return eventReturn.continue; } - protected async deferredKeyboardActivationValid(kbdPromise: Promise, metadata: KeyboardStub, target: OutputTarget): Promise { + protected async deferredKeyboardActivation( + kbdPromise: Promise, + metadata: KeyboardStub, + target: OutputTarget + ): Promise { const activation: PendingActivation = { target: target, keyboard: kbdPromise, @@ -140,6 +155,155 @@ export abstract class ContextManagerBase extends EventEmitter { await kbdPromise; // The keyboard-load is complete; is the activation still desired? - return this.findAndPopActivation(target); + const activationAfterAwait = this.findAndPopActivation(target); + if(activationAfterAwait == activation) { + return activation; + } else { + return null; + } + } + + /** + * Change active keyboard to keyboard selected by (internal) name and language code + * + * Test if selected keyboard already loaded, and simply update active stub if so. + * Otherwise, insert a script to download and insert the keyboard from the repository + * or user-indicated file location. + * + * TODO: The old 'recorder' tool stubbed the old _SetActiveKeyboard method, but should now stub this + * instead. Or, perhaps one of the newer internal events. + * @param keyboardId + * @param languageCode + * @param saveCookie + * @returns + */ + public async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise { + const activatingKeyboard = this.prepareKeyboardForActivation(keyboardId, languageCode); + const originalKeyboardTarget = this.keyboardTarget; + + // Triggers `beforeKeyboardChange` event + // - this.osk.startHide(false), when needed, could be called via handler here... + // and on 'onAsyncKeyboardLoad' + // Also include an 'abort' check based upon it. + if(!this.confirmKeyboardChange(activatingKeyboard.metadata)) { + return false; + } + + const keyboard = await activatingKeyboard.keyboard; + if(keyboard == null && activatingKeyboard.metadata) { + // Cancelled - the activation was async and no longer valid. + return false; + } + + this.setKeyboardActiveForTarget({ + keyboard: keyboard, + metadata: activatingKeyboard.metadata + }, this.keyboardTarget); + + // Only trigger `keyboardchange` events when they will affect the active context. + if(this.keyboardTarget == originalKeyboardTarget) { + // Perform standard context-reset ops, including processNewContextEvent. + this.resetContext(); + // Will trigger KeymanEngine handler that passes keyboard to the OSK, displays it. + this.emit('keyboardchange', this.activeKeyboard); + } + + return true; + } + + /** + * Based on the provided keyboard id and language code, selects and (if necessary) loads the + * corresponding keyboard but does not activate it. + * + * This acts as a helper to `activateKeyboard`, helping to centralize and DRY out the actual + * activation of the requested keyboard. + * @param keyboardId + * @param languageCode + * @returns + */ + protected prepareKeyboardForActivation( + keyboardId: string, + languageCode?: string + ): {keyboard: Promise, metadata: KeyboardStub} { + // Set default language code + languageCode ||= ''; + + // Check that the saved keyboard is currently registered + let requestedStub = this.keyboardCache.getStub(keyboardId, languageCode); + + // Mobile device addition: force selection of the first keyboard if none set + if(this._hostDevice.touchable && !requestedStub) { + // Pick the oldest-registered stub as default. + requestedStub = this.keyboardCache.defaultStub; + } else if(!requestedStub) { + return { + keyboard: Promise.resolve(null), + metadata: null + }; + } + + // Check if current keyboard matches requested keyboard, but not (necessarily) stub + if(keyboardId == this.activeKeyboard.metadata.id) { + const keyboard = this.activeKeyboard.keyboard; + // In this case, the keyboard is loaded; just update the stub. + + return { + keyboard: Promise.resolve(keyboard), + metadata: requestedStub + }; + } + + // Determine if the keyboard was previously loaded but is not active; use the cached, pre-loaded version if so. + let keyboard: Keyboard; + if(keyboard = this.keyboardCache.getKeyboardForStub(requestedStub)) { + return { + keyboard: Promise.resolve(keyboard), + metadata: requestedStub + }; + } else { + // It's async time - the keyboard is not preloaded within the cache. Use the stub's data to load it. + + // Provide a Promise for completion of the async load process. + const completionPromise = new ManagedPromise(); + this.emit('keyboardasyncload', requestedStub, completionPromise.corePromise); + + let keyboardPromise = this.keyboardCache.fetchKeyboardForStub(requestedStub); + let timeoutPromise = new Promise((resolve, reject) => { + const timeoutMsg = `Sorry, the ${requestedStub.name} keyboard for ${requestedStub.langName} is not currently available.`; + window.setTimeout(() => reject(new Error(timeoutMsg)), ContextManagerBase.TIMEOUT_THRESHOLD); + }); + + let combinedPromise = Promise.race([keyboardPromise, timeoutPromise]); + + // Ensure the async-load Promise completes properly. + combinedPromise.then(() => completionPromise.resolve(null)); + combinedPromise.catch((err) => { + completionPromise.resolve(err); + throw err; + }); + + // Now the fun part: note the original call's parameters as a pending activation. + let promise = this.deferredKeyboardActivation(keyboardPromise, requestedStub, this.keyboardTarget); + return { + keyboard: promise.then(async (activation) => { + // Is the activation we requested still pending, or was it cancelled in favor of a + // different activation in some manner? + if(!activation) { + // If the user chose to load a different keyboard afterward that would affect the same + // output target, the activation is no longer valid. + return Promise.resolve(null); + } else if(activation.target == this.keyboardTarget && !this.confirmKeyboardChange(requestedStub)) { + // If still valid, but it would affect the active output target, we provide another chance + // to cancel the keyboard change - after all, we're in an async op. + return Promise.resolve(null); + } else { + // If still valid but it won't affect the currently-active output target, we don't ask to verify. + // It wouldn't affect the active context, so a corresponding event would be too unclear / confusing. + return keyboardPromise; + } + }), + metadata: requestedStub + } + } } } \ No newline at end of file diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index c0505aad0c..99b8aaeace 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -97,10 +97,11 @@ export default class KeymanEngine< this.processor = new InputProcessor(config.hostDevice, worker, this.processorConfiguration()); this.contextManager.configure({ - resetKeyState: (target) => { + resetContext: (target) => { this.processor.keyboardProcessor.resetContext(target); }, - predictionContext: new PredictionContext(this.processor.languageProcessor, this.processor.keyboardProcessor) + predictionContext: new PredictionContext(this.processor.languageProcessor, this.processor.keyboardProcessor), + keyboardCache: this.keyboardRequisitioner.cache }); // TODO: configure that context-manager! @@ -143,11 +144,44 @@ export default class KeymanEngine< contextManager.on('keyboardchange', (kbd) => { this.refreshModel(); + // Hide OSK and do not update keyboard list if using internal keyboard (desktops). + // Condition will not be met for touch form-factors; they force selection of a + // default keyboard. + if(kbd.keyboard == null && kbd.metadata == null) { + this.osk.startHide(false); + } + if(this.osk) { this.osk.setNeedsLayout(); this.osk.activeKeyboard = kbd; + this.osk.present(); } }); + + contextManager.on('keyboardasyncload', (metadata) => { + /* Original implementation pre-modularization: + * + * > Force OSK display for CJK keyboards (keyboards using a pick list) + * + * A matching subcondition in the block below will ensure that the OSK activates pre-load + * for CJK keyboards. Yes, even before a CJK picker could ever show. We should be fine + * without the CJK check so long as a picker keyboard's OSK is kept activated post-load, + * when the picker actually needs to be kept persistently-active. + * `metadata` would be relevant a the CJK-check, which was based on language codes. + * + * Of course, as mobile devices don't have guaranteed physical keyboards... we need to + * keep the OSK visible for them, hence the actual block below. + */ + if(this.config.hostDevice.touchable && this.osk?.activationModel) { + this.osk.activationModel.enabled = true; + // Also note: the OSKView.mayDisable method returns false when hostDevice.touchable = false. + // The .startHide() call below will check that method before actually starting an OSK hide. + } + + // Always (temporarily) hide the OSK when loading a new keyboard, to ensure + // that a failure to load doesn't leave the current OSK displayed + this.osk?.startHide(false); + }) // #endregion } @@ -235,111 +269,9 @@ export default class KeymanEngine< } } - async setActiveKeyboard(keyboardId: string, languageCode?: string): Promise { - return this.activateKeyboard(keyboardId, languageCode, true); + async setActiveKeyboard(keyboardId: string, languageCode?: string): Promise { + return this.contextManager.activateKeyboard(keyboardId, languageCode, true); } - - protected async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise { - saveCookie ||= false; - - // TODO: beforeKeyboardChange - // - this.osk.startHide(false), when needed, could be called via handler here... - // and on 'onAsyncKeyboardLoad' - // Also include an 'abort' check based upon it. - - this.contextManager.activeKeyboard = await this.prepareKeyboardForActivation(keyboardId, languageCode); - - // TODO: keyboardChange - // - this.osk.present() could totally be part of the handler for the event. - - this.osk.present(); - } - - /** - * Based on the provided keyboard id and language code, selects and (if necessary) loads the - * corresponding keyboard but does not activate it. - * - * This acts as a helper to `activateKeyboard`, helping to centralize and DRY out the actual - * activation of the requested keyboard. - * @param keyboardId - * @param languageCode - * @returns - */ - protected async prepareKeyboardForActivation( - keyboardId: string, - languageCode?: string - ): Promise<{keyboard: Keyboard, metadata: KeyboardStub}> { - // Set default language code - languageCode ||= ''; - - // Check that the saved keyboard is currently registered - let requestedStub = this.keyboardRequisitioner.cache.getStub(keyboardId, languageCode); - - // Mobile device addition: force selection of the first keyboard if none set - if(this.config.softDevice.touchable && !requestedStub) { - // Pick the oldest-registered stub as default. - requestedStub = this.keyboardRequisitioner.cache.defaultStub; - } else if(!requestedStub) { - // Hide OSK and do not update keyboard list if using internal keyboard (desktops) - this.osk?.startHide(false); - - return Promise.resolve({ - keyboard: null, - metadata: null - }); - } - - // Check if current keyboard matches requested keyboard, but not (necessarily) stub - if(keyboardId == this.contextManager.activeKeyboard.metadata.id) { - const keyboard = this.contextManager.activeKeyboard.keyboard; - // In this case, the keyboard is loaded; just update the stub. - - return Promise.resolve({ - keyboard: keyboard, - metadata: requestedStub - }); - } - - // Determine if the keyboard was previously loaded but is not active and use the prior load if so. - let keyboard: Keyboard; - if(keyboard = this.keyboardRequisitioner.cache.getKeyboardForStub(requestedStub)) { - return Promise.resolve({ - keyboard: keyboard, - metadata: requestedStub - }); - } else { - // async time - the keyboard has not yet been loaded. - - // Original implementation: also checked for CJK and kept OSKs activated pre-load, - // before the picker could ever show. We should be fine without it so long as - // a picker keyboard's OSK is kept activated post-load. - if(this.config.hostDevice.touchable && this.osk?.activationModel) { - this.osk.activationModel.enabled = true; - } - - this.osk?.startHide(false); - - // TODO: Maybe make this an event of sorts? ... which means extending EventEmitter, etc. - // We aren't adding a 'new' event API set quite yet, though. - this.onKeyboardAsyncLoadStart(requestedStub); - - let keyboardPromise = this.keyboardRequisitioner.cache.fetchKeyboardForStub(requestedStub); - - let promise = this.contextManager.setActiveKeyboardAsync(keyboardPromise, requestedStub); - return promise.then(async (stillValid) => { - if(!stillValid) { - return Promise.resolve(null); - } - - return { - keyboard: await keyboardPromise, - metadata: requestedStub - }; - }); - } - } - - protected onKeyboardAsyncLoadStart(requestedStub: KeyboardStub) { } } // Intent: define common behaviors for both primary app types; each then subclasses & extends where needed. \ No newline at end of file diff --git a/web/src/engine/namespaced-main/keyboards/kmwkeyboards.ts b/web/src/engine/namespaced-main/keyboards/kmwkeyboards.ts index 4abce41d5c..311f2c8744 100644 --- a/web/src/engine/namespaced-main/keyboards/kmwkeyboards.ts +++ b/web/src/engine/namespaced-main/keyboards/kmwkeyboards.ts @@ -189,315 +189,6 @@ namespace com.keyman.keyboards { return p; } - /** - * Change active keyboard to keyboard selected by (internal) name and language code - * - * Test if selected keyboard already loaded, and simply update active stub if so. - * Otherwise, insert a script to download and insert the keyboard from the repository - * or user-indicated file location. - * - * Note that the test-case oriented 'recorder' stubs this method to provide active - * keyboard stub information. If changing this function, please ensure the recorder is - * not affected. - * - * @param {string} PInternalName - * @param {string=} PLgCode - * @param {boolean=} saveCookie - */ - _SetActiveKeyboard(PInternalName: string, PLgCode?: string, saveCookie?: boolean): Promise { - let n; - - let keyman = com.keyman.singleton; - - var util = keyman.util; - var domManager = keyman.domManager; - var osk = keyman.osk; - - let activeKeyboard = keyman.core.activeKeyboard; - - // Set default language code - if(arguments.length < 2 || (!PLgCode)) { - PLgCode='---'; - } - - // Check that the saved keyboard is currently registered - for(n=0; n < this.keyboardStubs.length; n++) { - if(PInternalName == this.keyboardStubs[n]['KI']) { - if(PLgCode == this.keyboardStubs[n]['KLC'] || PLgCode == '---') break; - // This 'n' is used in the condition for the block below... and that's all. - } - } - - // Mobile device addition: force selection of the first keyboard if none set - if(util.device.touchable && (PInternalName == '' || PInternalName == null || n >= this.keyboardStubs.length)) { - if(this.keyboardStubs.length != 0) { - PInternalName=this.keyboardStubs[0]['KI']; - PLgCode=this.keyboardStubs[0]['KLC']; - } - } - - // Save name of keyboard (with language code) as a cookie - if(arguments.length > 2 && saveCookie) { - this.saveCurrentKeyboard(PInternalName,PLgCode); - } - - // Check if requested keyboard and stub are currently active - if(this.activeStub && activeKeyboard && activeKeyboard.id == PInternalName - && this.activeStub['KI'] == PInternalName //this part of test should not be necessary, but keep anyway - && this.activeStub['KLC'] == PLgCode && !this.keymanweb.mustReloadKeyboard - ) return Promise.resolve(); - - // Check if current keyboard matches requested keyboard, but not stub - if(activeKeyboard && (activeKeyboard.id == PInternalName)) { - // If so, simply update the active stub - for(let Ln=0; Ln 0) { - var Ps = this.keyboardStubs[0]; - this._SetActiveKeyboard(Ps['KI'], Ps['KLC'], true); - } - }.bind(this); - - loadingStub.asyncLoader.timer = window.setTimeout(loadingStub.asyncLoader.callback, 10000); - - //Display the loading delay bar (Note: only append 'keyboard' if not included in name.) - if(!this.keymanweb.isEmbedded) { - util.wait('Installing keyboard
' + kbdName); - } - - // Installing the script immediately does not work reliably if two keyboards are - // loaded in succession if there is any delay in downloading the script. - // It works much more reliably if deferred (KMEW-101, build 356) - // The effect of a delay can also be tested, for example, by setting the timeout to 5000 - var manager = this; - loadingStub.asyncLoader.promise = new Promise(function(resolve, reject) { - window.setTimeout(function(){ - manager.installKeyboard(resolve, reject, loadingStub); - // To be modularized: activation after the keyboard's loaded. - },0); - }); - } - this.activeStub=this.keyboardStubs[Ln]; - return this.keyboardStubs[Ln].asyncLoader.promise; - } - } - this.keymanweb.domManager._SetTargDir(this.keymanweb.domManager.lastActiveElement); // I2077 - LTR/RTL timing - } - - // Initialize the OSK (provided that the base code has been loaded) - if(osk) { - osk._Load(); - util.addStyleSheet(domManager.setAttachmentFontStyle(this.activeStub.KF)); - } - return Promise.resolve(); - } - - /** - * Install a keyboard script that has been downloaded from a keyboard server - * Operates as the core of a Promise, hence the 'resolve' and 'reject' parameters. - * - * @param {Object} kbdStub keyboard stub to be loaded. - * - **/ - installKeyboard(resolve: () => void, reject: (message?: string) => void, kbdStub: KeyboardStub) { - var util = this.keymanweb.util; - var osk = this.keymanweb.osk; - - var Lscript = util._CreateElement('script'); - Lscript.charset="UTF-8"; // KMEW-89 - Lscript.type = 'text/javascript'; - - // Preserve any namespaced IDs by use of the script's id tag attribute! - if(this.keymanweb.isEmbedded) { - Lscript.id = kbdStub['KI']; - } - - var kbdFile = kbdStub['KF']; - var kbdLang = kbdStub['KL']; - var kbdName = kbdStub['KN']; - - const scriptSrc = this.keymanweb.getKeyboardPath(kbdFile); - - var manager = this; - let core = com.keyman.singleton.core; - let domManager = com.keyman.singleton.domManager; - - // Add a handler for cases where the new