From f7f26e87f892887f7331b8fcf22e7c9ffa237c8f Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Sat, 31 Aug 2024 21:37:16 +0700 Subject: [PATCH] feat(web): refactor loading of .js keyboards - split keyboard loading into loading into blob and then loading the script - look at first four bytes to see if it's a .js or a .kmx keyboard - for domKeyboardLoader, use fetch to get the blob, then use indirect eval to load the script, instead of injecting a script element. This does not yet implement the loading of .kmx keyboards. --- web/src/engine/keyboard/src/index.ts | 8 +- .../src/keyboards/keyboardLoadError.ts | 63 +++++++++++++ .../src/keyboards/keyboardLoaderBase.ts | 94 +++++-------------- .../keyboards/loaders/domKeyboardLoader.ts | 72 +++++--------- .../keyboards/loaders/nodeKeyboardLoader.ts | 18 +++- 5 files changed, 125 insertions(+), 130 deletions(-) create mode 100644 web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts diff --git a/web/src/engine/keyboard/src/index.ts b/web/src/engine/keyboard/src/index.ts index 28b6331c21..cc0e86db53 100644 --- a/web/src/engine/keyboard/src/index.ts +++ b/web/src/engine/keyboard/src/index.ts @@ -3,12 +3,8 @@ export * from "./keyboards/defaultLayouts.js"; export { default as Keyboard } from "./keyboards/keyboard.js"; export * from "./keyboards/keyboard.js"; export { KeyboardHarness, KeyboardKeymanGlobal, MinimalCodesInterface, MinimalKeymanGlobal } from "./keyboards/keyboardHarness.js"; -export { - default as KeyboardLoaderBase, - KeyboardLoadErrorBuilder, - KeyboardMissingError, - KeyboardScriptError -} from "./keyboards/keyboardLoaderBase.js"; +export { default as KeyboardLoaderBase, } from "./keyboards/keyboardLoaderBase.js"; +export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError } from './keyboards/keyboardLoadError.js' export { CloudKeyboardFont, internalizeFont, diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts new file mode 100644 index 0000000000..9a95c24ef2 --- /dev/null +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts @@ -0,0 +1,63 @@ +import { type KeyboardStub } from './keyboardLoaderBase.js'; + +export interface KeyboardLoadErrorBuilder { + scriptError(err?: Error): void; + missingError(err: Error): void; +} + +export class KeyboardScriptError extends Error { + public readonly cause; + + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } +} + +export class KeyboardMissingError extends Error { + public readonly cause; + + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } +} + +export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + missingError(err: Error) { + const msg = `Cannot find the keyboard at ${this.uri}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; + return new KeyboardScriptError(msg, err); + } +} + +export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly stub: KeyboardStub; + + constructor(stub: KeyboardStub) { + this.stub = stub; + } + + missingError(err: Error) { + const stub = this.stub; + const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const stub = this.stub; + const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; + return new KeyboardScriptError(msg, err); + } +} + diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts index d894eeb20c..8024609c88 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts @@ -1,69 +1,9 @@ import Keyboard from "./keyboard.js"; import { KeyboardHarness } from "./keyboardHarness.js"; import KeyboardProperties from "./keyboardProperties.js"; +import { KeyboardLoadErrorBuilder, StubBasedErrorBuilder, UriBasedErrorBuilder } from './keyboardLoadError.js'; -type KeyboardStub = KeyboardProperties & { filename: string }; - -export interface KeyboardLoadErrorBuilder { - scriptError(err?: Error): void; - missingError(err: Error): void; -} - -export class KeyboardScriptError extends Error { - public readonly cause; - - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } -} - -export class KeyboardMissingError extends Error { - public readonly cause; - - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } -} - -class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly uri: string; - - constructor(uri: string) { - this.uri = uri; - } - - missingError(err: Error) { - const msg = `Cannot find the keyboard at ${this.uri}.`; - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; - return new KeyboardScriptError(msg, err); - } -} - -class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly stub: KeyboardStub; - - constructor(stub: KeyboardStub) { - this.stub = stub; - } - - missingError(err: Error) { - const stub = this.stub; - const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const stub = this.stub; - const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; - return new KeyboardScriptError(msg, err); - } -} +export type KeyboardStub = KeyboardProperties & { filename: string }; export default abstract class KeyboardLoaderBase { private _harness: KeyboardHarness; @@ -78,21 +18,29 @@ export default abstract class KeyboardLoaderBase { public loadKeyboardFromPath(uri: string): Promise { this.harness.install(); - const promise = this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); - - return promise; + return this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); } public loadKeyboardFromStub(stub: KeyboardStub) { this.harness.install(); - let promise = this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub), stub.id); - - return promise; + return this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub)); } - protected abstract loadKeyboardInternal( - uri: string, - errorBuilder: KeyboardLoadErrorBuilder, - id?: string - ): Promise; + private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + const blob = await this.loadKeyboardBlob(uri); + + const script = await blob.text(); + if (script.startsWith('KXTS', 0)) { + // KMX or LDML (KMX+) keyboard + console.error("KMX keyboard loading is not yet implemented!"); + return null; + } + + // .js keyboard + return await this.loadKeyboardFromScript(script, errorBuilder); + } + + protected abstract loadKeyboardBlob(uri: string): Promise; + + protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise; } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts index fcb2c764cd..abfea28a91 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts @@ -2,9 +2,10 @@ /// -import { Keyboard, KeyboardHarness, KeyboardLoaderBase, KeyboardLoadErrorBuilder, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; - -import { ManagedPromise } from '@keymanapp/web-utils'; +import { default as Keyboard } from '../keyboard.js'; +import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; +import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder, KeyboardMissingError } from '../keyboardLoadError.js'; export class DOMKeyboardLoader extends KeyboardLoaderBase { public readonly element: HTMLIFrameElement; @@ -28,54 +29,23 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { this.performCacheBusting = cacheBust || false; } - protected loadKeyboardInternal( - uri: string, - errorBuilder: KeyboardLoadErrorBuilder, - id?: string - ): Promise { - const promise = new ManagedPromise(); - - if(this.performCacheBusting) { + protected async loadKeyboardBlob(uri: string): Promise { + if (this.performCacheBusting) { uri = this.cacheBust(uri); } - try { - const document = this.harness._jsGlobal.document; - const script = document.createElement('script'); - if(id) { - script.id = id; - } - document.head.appendChild(script); - script.onerror = (err: any) => { - promise.reject(errorBuilder.missingError(err)); - } - script.onload = () => { - if(this.harness.loadedKeyboard) { - const keyboard = this.harness.loadedKeyboard; - this.harness.loadedKeyboard = null; - promise.resolve(keyboard); - } else { - promise.reject(errorBuilder.scriptError()); - } - } - - // On the oldest mobile devices we support, Promise.finally may not actually exist. - // Fortunately... it's not that hard of an issue to work around. - // Note: es6-shim doesn't polyfill Promise.finally! - promise.then(() => { - // It is safe to remove the script once it has been run (https://stackoverflow.com/a/37393041) - script.remove(); - }).catch(() => { - script.remove(); - }); - - // Now that EVERYTHING ELSE is ready, establish the link to the keyboard's script. - script.src = uri; - } catch (err) { - return Promise.reject(err); + const response = await fetch(uri); + if (!response.ok) { + throw new KeyboardMissingError(`Cannot find the keyboard at ${uri}.`, new Error(`HTTP ${response.status} ${response.statusText}`)); } + return response.blob(); + } - return promise.corePromise; + protected async loadKeyboardFromScript(script: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + this.evalScriptInContext(script, this.harness._jsGlobal); + const keyboard = this.harness.loadedKeyboard; + this.harness.loadedKeyboard = null; + return keyboard; } private cacheBust(uri: string) { @@ -84,4 +54,14 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { // being ignored. return uri + "?v=" + (new Date()).getTime(); /*cache buster*/ } + + private evalScriptInContext(script: string, context: any) { + const f = function (s: string) { + // use indirect eval (eval?.() notation doesn't work because of esbuild bundling) + const evalFunc = eval; + return evalFunc(s); + } + f.call(context, script); + } + } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts index b9f6a7cdfe..ca0874dfd8 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -1,7 +1,10 @@ -import { Keyboard, KeyboardHarness, KeyboardLoaderBase, KeyboardLoadErrorBuilder, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; +import { default as Keyboard } from '../keyboard.js'; +import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; +import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js'; import vm from 'vm'; -import fs from 'fs'; +import { readFile } from 'fs/promises'; import { globalObject } from '@keymanapp/web-utils'; export class NodeKeyboardLoader extends KeyboardLoaderBase { @@ -22,15 +25,20 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { } } - protected loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + protected async loadKeyboardBlob(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) { + if (uri.indexOf('file:///') == 0) { uri = uri.substring('file:///'.length); } + const buffer = await readFile(uri); + return new Blob([buffer]); + } + + protected async loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { let script; try { - script = new vm.Script(fs.readFileSync(uri).toString()); + script = new vm.Script(scriptSrc); } catch (err) { return Promise.reject(errorBuilder.missingError(err)); }