Merge pull request #13290 from keymanapp/fix/web/workerless-engine-support

fix(web): properly support workerless use of Web engine
This commit is contained in:
Joshua Horton 2025-02-25 08:17:33 +07:00 • committed by GitHub
commit 69c80d31e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 84 additions and 37 deletions

View file

@ -13,4 +13,4 @@ import { SourcemappedWorker } from '@keymanapp/lexical-model-layer/web'
var sPath = ss.substr(0,ss.lastIndexOf('/')+1);
// @ts-ignore
window['keyman'] = new KeymanEngine(SourcemappedWorker.constructInstance(), sPath);
window['keyman'] = new KeymanEngine(SourcemappedWorker, sPath);

View file

@ -27,6 +27,7 @@ import { UIModule } from './uiModuleInterface.js';
import { HotkeyManager } from './hotkeyManager.js';
import { BeepHandler } from './beepHandler.js';
import KeyboardInterface from './keyboardInterface.js';
import { WorkerFactory } from '@keymanapp/lexical-model-layer/web';
export default class KeymanEngine extends KeymanEngineBase<BrowserConfiguration, ContextManager, HardwareEventKeyboard> {
touchLanguageMenu?: LanguageMenu;
@ -55,10 +56,10 @@ export default class KeymanEngine extends KeymanEngineBase<BrowserConfiguration,
this.contextManager.restoreLastActiveTarget();
}
constructor(worker: Worker, sourceUri: string) {
constructor(workerFactory: WorkerFactory, sourceUri: string) {
const config = new BrowserConfiguration(sourceUri); // currently set to perform device auto-detect.
super(worker, config, new ContextManager(config, () => this.legacyAPIEvents), (engine) => {
super(workerFactory, config, new ContextManager(config, () => this.legacyAPIEvents), (engine) => {
return {
// The `engine` parameter cannot be supplied with the constructing instance before calling
// `super`, hence the 'fun' rigging to supply it _from_ `super` via this closure.

View file

@ -13,4 +13,4 @@ import { Worker } from '@keymanapp/lexical-model-layer/web'
var sPath = ss.substr(0,ss.lastIndexOf('/')+1);
// @ts-ignore
window['keyman'] = new KeymanEngine(Worker.constructInstance(), sPath);
window['keyman'] = new KeymanEngine(Worker, sPath);

View file

@ -13,4 +13,4 @@ const ss = scripts[scripts.length-1].src;
const sPath = ss.substring(0,ss.lastIndexOf('/')+1);
// @ts-ignore
window['keyman'] = new KeymanEngine(SourcemappedWorker.constructInstance(), sPath);
window['keyman'] = new KeymanEngine(SourcemappedWorker, sPath);

View file

@ -9,12 +9,13 @@ import { WebviewConfiguration, WebviewInitOptionDefaults, WebviewInitOptionSpec
import ContextManager, { ContextHost } from './contextManager.js';
import PassthroughKeyboard from './passthroughKeyboard.js';
import { buildEmbeddedGestureConfig, setupEmbeddedListeners } from './oskConfiguration.js';
import { WorkerFactory } from '@keymanapp/lexical-model-layer';
export default class KeymanEngine extends KeymanEngineBase<WebviewConfiguration, ContextManager, PassthroughKeyboard> {
// Ideally, we would be able to auto-detect `sourceUri`: https://stackoverflow.com/a/60244278.
// But it's too new of a feature to utilize... and also expects to be in a module, when this may
// be compiled down to an IIFE.
constructor(worker: Worker, sourceUri: string) {
constructor(workerFactory: WorkerFactory, sourceUri: string) {
const config = new WebviewConfiguration(sourceUri); // currently set to perform device auto-detect.
config.onRuleFinalization = (ruleBehavior: RuleBehavior) => {
@ -30,7 +31,7 @@ export default class KeymanEngine extends KeymanEngineBase<WebviewConfiguration,
}
}
super(worker, config, new ContextManager(config), (engine) => {
super(workerFactory, config, new ContextManager(config), (engine) => {
return {
// The `engine` parameter cannot be supplied with the constructing instance before calling
// `super`, hence the 'fun' rigging to supply it _from_ `super` via this closure.

View file

@ -13,4 +13,4 @@ const ss = scripts[scripts.length-1].src;
const sPath = ss.substring(0,ss.lastIndexOf('/')+1);
// @ts-ignore
window['keyman'] = new KeymanEngine(Worker.constructInstance(), sPath);
window['keyman'] = new KeymanEngine(Worker, sPath);

View file

@ -20,6 +20,7 @@ import {
import { TranscriptionCache } from "./transcriptionCache.js";
import { LexicalModelTypes } from '@keymanapp/common-types';
import { WorkerFactory } from "@keymanapp/lexical-model-layer";
export class InputProcessor {
public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = {
@ -37,7 +38,7 @@ export class InputProcessor {
private readonly contextCache = new TranscriptionCache();
constructor(device: DeviceSpec, predictiveTextWorker: Worker, options?: ProcessorInitOptions) {
constructor(device: DeviceSpec, predictiveWorkerFactory: WorkerFactory, options?: ProcessorInitOptions) {
if(!device) {
throw new Error('device must be defined');
}
@ -48,7 +49,7 @@ export class InputProcessor {
this.contextDevice = device;
this.kbdProcessor = new KeyboardProcessor(device, options);
this.lngProcessor = new LanguageProcessor(predictiveTextWorker, this.contextCache);
this.lngProcessor = new LanguageProcessor(predictiveWorkerFactory, this.contextCache);
}
public get languageProcessor(): LanguageProcessor {

View file

@ -1,5 +1,5 @@
import { EventEmitter } from "eventemitter3";
import { LMLayer } from "@keymanapp/lexical-model-layer/web";
import { LMLayer, WorkerFactory } from "@keymanapp/lexical-model-layer/web";
import { OutputTarget, Transcription, Mock } from "keyman/engine/js-processor";
import { LanguageProcessorEventMap, ModelSpec, StateChangeEnum, ReadySuggestions } from 'keyman/engine/interfaces';
import ContextWindow from "./contextWindow.js";
@ -25,7 +25,7 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
private _state: StateChangeEnum = 'inactive';
public constructor(predictiveTextWorker: Worker, transcriptionCache: TranscriptionCache, supportsRightDeletions: boolean = false) {
public constructor(predictiveWorkerFactory: WorkerFactory, transcriptionCache: TranscriptionCache, supportsRightDeletions: boolean = false) {
super();
this.recentTranscriptions = transcriptionCache;
@ -38,11 +38,21 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
maxRightContextCodePoints: supportsRightDeletions ? 0 : 64
}
if(!predictiveTextWorker) {
if(!predictiveWorkerFactory) {
return;
}
this.lmEngine = new LMLayer(capabilities, predictiveTextWorker);
let workerInstance: Worker;
try {
workerInstance = predictiveWorkerFactory?.constructInstance();
} catch(e) {
// We can condition on `lmEngine` being null/undefined.
console.warn('Web workers are not available: ' + (e ?? '').toString());
workerInstance = null;
}
if(workerInstance) {
this.lmEngine = new LMLayer(capabilities, workerInstance);
}
}
public get activeModel(): ModelSpec {
@ -58,6 +68,10 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public unloadModel() {
if(!this.isActive) {
return;
}
this.lmEngine.unloadModel();
delete this.currentModel;
delete this.configuration;
@ -71,6 +85,10 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
throw new Error("Null reference not allowed.");
}
if(!this.canEnable) {
return Promise.resolve();
}
let specType: 'file'|'raw' = model.path ? 'file' : 'raw';
let source = specType == 'file' ? model.path : model.code;
@ -108,9 +126,6 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public invalidateContext(outputTarget: OutputTarget, layerId: string): Promise<Suggestion[]> {
// Signal to any predictive text UI that the context has changed, invalidating recent predictions.
this.emit('invalidatesuggestions', 'context');
// If there's no active model, there can be no predictions.
// We'll also be missing important data needed to even properly REQUEST the predictions.
if(!this.currentModel || !this.configuration) {
@ -121,7 +136,12 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
// invalidateContext otherwise bypasses .predict()'s check against this.
if(!this.isActive) {
return Promise.resolve([]);
} else if(outputTarget) {
}
// Signal to any predictive text UI that the context has changed, invalidating recent predictions.
this.emit('invalidatesuggestions', 'context');
if(outputTarget) {
let transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false);
return this.predict_internal(transcription, true, layerId);
} else {
@ -173,6 +193,10 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
throw "Accepting suggestions requires a destination OutputTarget instance."
}
if(!this.isActive) {
return null;
}
if(!this.isConfigured) {
// If we're in this state, the suggestion is now outdated; the user must have swapped keyboard and model.
console.warn("Could not apply suggestion; the corresponding model has been unloaded");
@ -244,6 +268,10 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
throw "Accepting suggestions requires a destination OutputTarget instance."
}
if(!this.isActive) {
return null;
}
// Find the state of the context at the time the suggestion was generated.
// This may refer to the context before an input keystroke or before application
// of a predictive suggestion.
@ -278,7 +306,7 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public predictFromTarget(outputTarget: OutputTarget, layerId: string): Promise<Suggestion[]> {
if(!outputTarget) {
if(!this.isActive || !outputTarget) {
return null;
}
@ -292,7 +320,7 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
* @param transcription The triggering transcription (if it exists)
*/
private predict_internal(transcription: Transcription, resetContext: boolean, layerId: string): Promise<Suggestion[]> {
if(!transcription) {
if(!this.isActive || !transcription) {
return null;
}
@ -341,19 +369,19 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public shutdown() {
this.lmEngine.shutdown();
this.lmEngine?.shutdown();
this.removeAllListeners();
}
public get isActive(): boolean {
if(!this.canEnable()) {
if(!this.canEnable) {
this._mayPredict = false;
return false;
}
return (this.activeModel || false) && this._mayPredict;
}
canEnable(): boolean {
public get canEnable(): boolean {
// Is not initialized if there is no worker.
return !!this.lmEngine;
}
@ -363,7 +391,7 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public set mayPredict(flag: boolean) {
if(!this.canEnable()) {
if(!this.canEnable) {
return;
}
@ -416,6 +444,10 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public tryAcceptSuggestion(source: string): boolean {
if(!this.isActive) {
return false;
}
// The object below is to facilitate a pass-by-reference on the boolean flag,
// allowing the event's handler to signal if whitespace has been added via
// auto-applied suggestion that should be blocked on the next keystroke.
@ -426,6 +458,10 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public tryRevertSuggestion(): boolean {
if(!this.isActive) {
return false;
}
// If and when we do auto-revert, the suggestion is to pass this object to the event and
// denote any mutations to the contained value.
//let returnObj = {shouldSwallow: false};

View file

@ -1,6 +1,7 @@
import { type KeyEvent, type Keyboard, KeyboardKeymanGlobal } from "keyman/engine/keyboard";
import { OutputTarget, ProcessorInitOptions, RuleBehavior } from 'keyman/engine/js-processor';
import { DOMKeyboardLoader as KeyboardLoader } from "keyman/engine/keyboard/dom-keyboard-loader";
import { WorkerFactory } from "@keymanapp/lexical-model-layer/web"
import { InputProcessor } from './headless/inputProcessor.js';
import { OSKView } from "keyman/engine/osk";
import { KeyboardRequisitioner, ModelCache, toUnprefixedKeyboardId as unprefixed } from "keyman/engine/keyboard-storage";
@ -112,7 +113,7 @@ export default class KeymanEngine<
* the superclass constructor.
*/
constructor(
worker: Worker,
workerFactory: WorkerFactory,
config: Configuration,
contextManager: ContextManager,
processorConfigInitializer: (engine: KeymanEngine<Configuration, ContextManager, HardKeyboard>) => ProcessorConfiguration
@ -123,7 +124,7 @@ export default class KeymanEngine<
const processorConfiguration = processorConfigInitializer(this);
processorConfiguration.baseLayout = determineBaseLayout();
this.interface = processorConfiguration.keyboardInterface as KeyboardInterface<ContextManager>;
this.core = new InputProcessor(config.hostDevice, worker, processorConfiguration);
this.core = new InputProcessor(config.hostDevice, workerFactory, processorConfiguration);
this.core.languageProcessor.on('statechange', (state) => {
// The banner controller cannot directly trigger a layout-refresh at this time,

View file

@ -1,3 +1,7 @@
export { default as LMLayer } from './lmlayer.js';
export { default as NodeWorker } from './node/worker.js';
export { default as WebWorker } from './web/worker.js';
export { default as WebWorker } from './web/worker.js';
export interface WorkerFactory {
constructInstance(): Worker
}

View file

@ -1,3 +1,7 @@
export { default as LMLayer } from '../lmlayer.js';
export { default as Worker } from './worker.js';
export { default as SourcemappedWorker } from './sourcemappedWorker.js';
export { default as SourcemappedWorker } from './sourcemappedWorker.js';
export interface WorkerFactory {
constructInstance(): Worker
}

View file

@ -1,3 +1,7 @@
export { default as LMLayer } from '../lmlayer.js';
export { default as Worker } from './worker.js';
export { default as SourcemappedWorker } from './sourcemappedWorker.js';
export { default as SourcemappedWorker } from './sourcemappedWorker.js';
export interface WorkerFactory {
constructInstance(): Worker
}

View file

@ -64,18 +64,17 @@ function dummiedGetLayer() {
}
describe("PredictionContext", () => {
let worker;
let langProcessor;
beforeEach(function() {
worker = LMWorker.constructInstance();
langProcessor = new LanguageProcessor(LMWorker, new TranscriptionCache());
});
afterEach(function() {
worker.terminate();
langProcessor.shutdown();
});
it('receives predictions as they are generated', async function () {
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
const predictiveContext = new PredictionContext(langProcessor, dummiedGetLayer);
@ -115,7 +114,6 @@ describe("PredictionContext", () => {
});
it('ignores outdated predictions', async function () {
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
const predictiveContext = new PredictionContext(langProcessor, dummiedGetLayer);
@ -179,7 +177,6 @@ describe("PredictionContext", () => {
});
it('sendUpdateState retrieves the most recent suggestion set', async function() {
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
const predictiveContext = new PredictionContext(langProcessor, dummiedGetLayer);
@ -204,7 +201,6 @@ describe("PredictionContext", () => {
});
it('suggestion application logic & triggered effects', async function () {
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
const predictiveContext = new PredictionContext(langProcessor, dummiedGetLayer);
@ -271,7 +267,6 @@ describe("PredictionContext", () => {
});
it('reversion application logic & triggered effects', async function () {
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
const predictiveContext = new PredictionContext(langProcessor, dummiedGetLayer);