UNPKG

@difizen/mana-core

Version:

295 lines (264 loc) 8.85 kB
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; @singleton() 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( @inject(WindowService) windowsService: WindowService, @contrib(ApplicationContribution) contributions: Contribution.Provider<ApplicationContribution>, @inject(ApplicationStateService) stateService: ApplicationStateService, @inject(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; } }