@difizen/mana-core
Version:
295 lines (264 loc) • 8.85 kB
text/typescript
import type { MaybePromise } from '@difizen/mana-common';
import { Disposable } from '@difizen/mana-common';
import { Emitter, DisposableCollection } from '@difizen/mana-common';
import { inject, singleton, Syringe, contrib, Utils } from '@difizen/mana-syringe';
import type { Decorator, Contribution } from '@difizen/mana-syringe';
import { DebugService } from '../common/debug';
import { WindowService } from './application-protocol';
import { ApplicationStateService, ApplicationState } from './application-state';
export const ApplicationContribution = Syringe.defineToken('ApplicationContribution');
export type ApplicationContribution = {
/**
* Called on application startup.
*/
initialize?: () => void;
/**
* Called before the start of the contributions to complete asynchronous initialization.
*/
onWillStart?: (app: Application) => MaybePromise<void>;
/**
* Called when the application is starting.
*/
onStart?: (app: Application) => MaybePromise<void>;
/**
* Called when the application is started and after the root view has been created.
*/
onViewStart?: (app: Application) => MaybePromise<void>;
/**
* Called on `beforeunload` event, return `true` in order to prevent exit.
*/
onWillStop?: (app: Application) => boolean;
/**
* Called when an application is stopped.
* Note: this is the last tick.
*/
onStop?: (app: Application) => void;
};
export function application<T = any>(
option: Syringe.DecoratorOption<T> = {},
): Decorator<T> {
return singleton({
...option,
contrib: [ApplicationContribution, ...Utils.toArray(option.contrib)],
lifecycle: Syringe.Lifecycle.singleton,
});
}
const TIMER_WARNING_THRESHOLD = 100;
()
export class Application {
protected _host?: HTMLElement;
started = false;
protected onKeyDownEmitter = new Emitter<KeyboardEvent>();
protected onHostChangedEmitter = new Emitter<HTMLElement | undefined>();
protected unmountToDispose = new DisposableCollection();
protected readonly windowsService: WindowService;
protected readonly contributions: Contribution.Provider<ApplicationContribution>;
protected readonly stateService: ApplicationStateService;
protected readonly debugService: DebugService;
get host(): HTMLElement | undefined {
return this._host;
}
set host(v: HTMLElement | undefined) {
this._host = v;
this.onHostChangedEmitter.fire(v);
}
get onKeyDown() {
return this.onKeyDownEmitter.event;
}
get onHostChanged() {
return this.onHostChangedEmitter.event;
}
constructor(
(WindowService)
windowsService: WindowService,
(ApplicationContribution)
contributions: Contribution.Provider<ApplicationContribution>,
(ApplicationStateService)
stateService: ApplicationStateService,
(DebugService)
debugService: DebugService,
) {
this.windowsService = windowsService;
this.contributions = contributions;
this.stateService = stateService;
this.debugService = debugService;
}
/**
* Start the frontend application.
*
* Start up consists of the following steps:
* - start frontend contributions
* - create root view and render it
* - restore or create other views and render them
*/
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
await this.initialize();
this.stateService.setState(ApplicationState.Initialized);
await this.onStart();
this.stateService.setState(ApplicationState.Started);
await this.viewStart();
this.mount();
this.stateService.setState(ApplicationState.ViewStarted);
this.stateService.setState(ApplicationState.Ready);
this.windowsService.onUnload(() => {
this.stateService.setState(ApplicationState.Closing);
this.onStop();
});
}
public inComposition = false;
/**
* Register composition related event listeners.
*/
protected registerCompositionEventListeners(): Disposable {
const compositionStart = () => {
this.inComposition = true;
};
const compositionEnd = () => {
this.inComposition = false;
};
document.addEventListener('compositionstart', compositionStart);
document.addEventListener('compositionend', compositionEnd);
return Disposable.create(() => {
document.removeEventListener('compositionstart', compositionStart);
document.removeEventListener('compositionend', compositionEnd);
});
}
protected registerKeyDownListener(): Disposable {
const fireKeyDown = (e: KeyboardEvent) => {
this.onKeyDownEmitter.fire(e);
};
document.addEventListener('keydown', fireKeyDown);
return Disposable.create(() => {
document.removeEventListener('keydown', fireKeyDown);
});
}
mount() {
this.unmountToDispose.push(this.registerCompositionEventListeners());
this.unmountToDispose.push(this.registerKeyDownListener());
}
unmount() {
this.unmountToDispose.dispose();
}
/**
* Initialize and start the frontend application contributions.
*/
protected async initialize(): Promise<void> {
for (const contribution of this.contributions.getContributions()) {
if (contribution.initialize) {
try {
await this.measure(`${contribution.constructor.name}.initialize`, () => {
if (contribution.initialize) {
contribution.initialize();
}
});
} catch (error) {
console.error('contribution initialize', error);
}
}
}
}
/**
* Initialize and start the frontend application contributions.
*/
protected async onStart(): Promise<void> {
for (const contribution of this.contributions.getContributions()) {
if (contribution.onWillStart) {
try {
await this.measure(`${contribution.constructor.name}.onWillStart`, () => {
if (contribution.onWillStart) {
return contribution.onWillStart(this);
}
});
} catch (error) {
console.error('contribution onWillStart', error);
}
}
}
for (const contribution of this.contributions.getContributions()) {
if (contribution.onStart) {
try {
await this.measure(`${contribution.constructor.name}.onStart`, () => {
if (contribution.onStart) {
return contribution.onStart!(this);
}
});
} catch (error) {
console.error('contribution onStart', error);
}
}
}
}
protected async viewStart(): Promise<void> {
for (const contribution of this.contributions.getContributions()) {
if (contribution.onViewStart) {
try {
await this.measure(
`${contribution.constructor.name}.initializeLayout`,
() => {
if (contribution.onViewStart) {
return contribution.onViewStart(this);
}
},
);
} catch (error) {
console.error('contribution initializeLayout', error);
}
}
}
}
onWillStop(): boolean {
let shouldConfirm = false;
for (const contribution of this.contributions.getContributions()) {
if (contribution.onWillStop) {
try {
if (contribution.onWillStop) {
if (contribution.onWillStop(this)) {
shouldConfirm = true;
}
}
} catch (error) {
console.error('contribution onWillStop', error);
}
}
}
return shouldConfirm;
}
/**
* Stop the frontend application contributions. This is called when the window is unloaded.
*/
protected onStop(): void {
this.debugService('>>> Stopping contributions...');
for (const contribution of this.contributions.getContributions()) {
if (contribution.onStop) {
try {
contribution.onStop(this);
} catch (error) {
console.error('contribution onStop', error);
}
}
}
this.debugService('<<< All contributions have been stopped.');
}
protected async measure<T>(name: string, fn: () => MaybePromise<T>): Promise<T> {
const startMark = `${name}-start`;
const endMark = `${name}-end`;
performance.mark(startMark);
const result = await fn();
performance.mark(endMark);
performance.measure(name, startMark, endMark);
for (const item of performance.getEntriesByName(name)) {
const contribution = `${item.name}`;
if (item.duration > TIMER_WARNING_THRESHOLD) {
console.warn(`${contribution} is slow, took: ${item.duration.toFixed(1)} ms`);
} else {
this.debugService(`${contribution} took: ${item.duration.toFixed(1)} ms`);
}
}
performance.clearMeasures(name);
return result;
}
}