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.
This commit is contained in:
Eberhard Beilharz 2024-08-31 21:37:16 +07:00
parent 68566af3f9
commit f7f26e87f8
No known key found for this signature in database
GPG key ID: E9140597606020D3
5 changed files with 125 additions and 130 deletions

View file

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

View file

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

View file

@ -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<Keyboard> {
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<Keyboard>;
private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard> {
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<Blob>;
protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise<Keyboard>;
}

View file

@ -2,9 +2,10 @@
///<reference lib="dom" />
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<Keyboard> {
const promise = new ManagedPromise<Keyboard>();
if(this.performCacheBusting) {
protected async loadKeyboardBlob(uri: string): Promise<Blob> {
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<Keyboard> {
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);
}
}

View file

@ -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<Keyboard> {
protected async loadKeyboardBlob(uri: string): Promise<Blob> {
// `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<Keyboard> {
let script;
try {
script = new vm.Script(fs.readFileSync(uri).toString());
script = new vm.Script(scriptSrc);
} catch (err) {
return Promise.reject(errorBuilder.missingError(err));
}