From f5ce793d5ae6a28e2c72a6b74e5cce05ae7bf580 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Tue, 19 Mar 2024 15:10:50 +0100 Subject: [PATCH] feat: splash screen support for Electron Enhances the ElectronMainApplication to optionally render a splash screen until the frontend is ready. The splash screen can be configured via the application config object "theia.frontend.config.electron.showWindowEarly". Mandatory is the option "content" which specifies a relative path from the frontend location to the content of the splash screen. Optionally "width", "height", "minDuration" and "maxDuration" can be handed over too. Configures the Electron example application to show a Theia logo splash screen. Implements #13410 Contributed on behalf of Pragmatiqu IT GmbH --- CHANGELOG.md | 3 +- .../src/application-props.ts | 35 ++++- examples/electron/package.json | 8 +- examples/electron/resources/theia-logo.svg | 32 +++++ .../electron-main-application.ts | 127 ++++++++++++++++-- .../electron-main/theia-electron-window.ts | 10 +- 6 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 examples/electron/resources/theia-logo.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2d3bb4353fe..53865bfdddee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ ## not yet released - [application-package] bumped the default supported API from `1.86.2` to `1.87.2` [#13514](https://github.com/eclipse-theia/theia/pull/13514) - contributed on behalf of STMicroelectronics -- [core] Fix quickpick problems found in IDE testing [#13451](https://github.com/eclipse-theia/theia/pull/13451) - contributed on behalf of STMicroelectronics +- [core] Fix quickpick problems found in IDE testing [#13451](https://github.com/eclipse-theia/theia/pull/13451) - contributed on behalf of STMicroelectronics +- [core] Splash Screen Support for Electron [#13505](https://github.com/eclipse-theia/theia/pull/13505) - contributed on behalf of Pragmatiqu IT GmbH - [plugin] Extend TextEditorLineNumbersStyle with Interval [#13458](https://github.com/eclipse-theia/theia/pull/13458) - contributed on behalf of STMicroelectronics [Breaking Changes:](#breaking_changes_not_yet_released) diff --git a/dev-packages/application-package/src/application-props.ts b/dev-packages/application-package/src/application-props.ts index a443a60df679d..7567698b5b610 100644 --- a/dev-packages/application-package/src/application-props.ts +++ b/dev-packages/application-package/src/application-props.ts @@ -35,6 +35,33 @@ export namespace ElectronFrontendApplicationConfig { windowOptions: {}, showWindowEarly: true }; + export interface SplashScreenOptions { + /** + * Initial width of the splash screen. Defaults to 640. + */ + width?: number; + /** + * Initial height of the splash screen. Defaults to 480. + */ + height?: number; + /** + * Minimum amount of time in milliseconds to show the splash screen before main window is shown. + * Defaults to 0, i.e. the splash screen will be shown until the frontend application is ready. + */ + minDuration?: number; + /** + * Maximum amount of time in milliseconds before splash screen is removed and main window is shown. + * Defaults to 60000. + */ + maxDuration?: number; + /** + * The content to load in the splash screen. + * Will be resolved from application root. + * + * Mandatory attribute. + */ + content?: string; + } export interface Partial { /** @@ -45,11 +72,11 @@ export namespace ElectronFrontendApplicationConfig { readonly windowOptions?: BrowserWindowConstructorOptions; /** - * Whether or not to show an empty Electron window as early as possible. - * - * Defaults to `true`. + * Whether or not to show an empty Electron main window as early as possible. + * Alternatively a splash screen can be configured which is shown until the + * frontend is ready. */ - readonly showWindowEarly?: boolean; + readonly showWindowEarly?: boolean | SplashScreenOptions; } } diff --git a/examples/electron/package.json b/examples/electron/package.json index 2e04a9ffcf28b..5202e1d98dfd0 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -10,7 +10,13 @@ "frontend": { "config": { "applicationName": "Theia Electron Example", - "reloadOnReconnect": true + "reloadOnReconnect": true, + "electron": { + "showWindowEarly": { + "content": "resources/theia-logo.svg", + "height": 90 + } + } } }, "backend": { diff --git a/examples/electron/resources/theia-logo.svg b/examples/electron/resources/theia-logo.svg new file mode 100644 index 0000000000000..8d6b150a81d6b --- /dev/null +++ b/examples/electron/resources/theia-logo.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/packages/core/src/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 2efa9fce419e2..9b528cc7850c9 100644 --- a/packages/core/src/electron-main/electron-main-application.ts +++ b/packages/core/src/electron-main/electron-main-application.ts @@ -22,7 +22,7 @@ import { AddressInfo } from 'net'; import { promises as fs } from 'fs'; import { existsSync, mkdirSync } from 'fs-extra'; import { fork, ForkOptions } from 'child_process'; -import { DefaultTheme, FrontendApplicationConfig } from '@theia/application-package/lib/application-props'; +import { DefaultTheme, ElectronFrontendApplicationConfig, FrontendApplicationConfig } from '@theia/application-package/lib/application-props'; import URI from '../common/uri'; import { FileUri } from '../common/file-uri'; import { Deferred } from '../common/promise-util'; @@ -136,6 +136,16 @@ export class ElectronMainProcessArgv { } +interface SplashScreenState { + splashScreenWindow?: BrowserWindow; + minTime: Promise; + maxTime: Promise; +} + +interface SplashScreenOptions extends ElectronFrontendApplicationConfig.SplashScreenOptions { + content: string; +} + export namespace ElectronMainProcessArgv { export interface ElectronMainProcess extends NodeJS.Process { readonly defaultApp: boolean; @@ -184,6 +194,8 @@ export class ElectronMainApplication { protected initialWindow?: BrowserWindow; + protected splashScreenState?: SplashScreenState; + get config(): FrontendApplicationConfig { if (!this._config) { throw new Error('You have to start the application first.'); @@ -224,6 +236,7 @@ export class ElectronMainApplication { this.useNativeWindowFrame = this.getTitleBarStyle(config) === 'native'; this._config = config; this.hookApplicationEvents(); + this.showSplashScreen(); this.showInitialWindow(); const port = await this.startBackend(); this._backendPort.resolve(port); @@ -287,18 +300,87 @@ export class ElectronMainApplication { return this.didUseNativeWindowFrameOnStart.get(webContents.id) ? 'native' : 'custom'; } + /** + * Shows the splash screen, if it was configured. Otherwise does nothing. + */ + protected showSplashScreen(): void { + if (this.isShowSplashScreen()) { + console.log('Showing splash screen'); + const splashScreenOptions = this.getSplashScreenOptions(); + if (!splashScreenOptions) { + // sanity check, should always exist here + return; + } + const content = splashScreenOptions.content; + console.debug('SplashScreen options', splashScreenOptions); + app.whenReady().then(() => { + const splashScreenBounds = this.determineSplashScreenBounds(); + const splashScreenWindow = new BrowserWindow({ + ...splashScreenBounds, + frame: false, + alwaysOnTop: true, + }); + splashScreenWindow.show(); + splashScreenWindow.loadFile(path.resolve(this.globals.THEIA_APP_PROJECT_PATH, content).toString()); + this.splashScreenState = { + splashScreenWindow, + minTime: new Promise(resolve => setTimeout(() => resolve(), splashScreenOptions.minDuration ?? 0)), + maxTime: new Promise(resolve => setTimeout(() => resolve(), splashScreenOptions.maxDuration ?? 60000)), + }; + }); + } + } + + protected determineSplashScreenBounds(): { x: number, y: number, width: number, height: number } { + const splashScreenOptions = this.getSplashScreenOptions(); + const width = splashScreenOptions?.width ?? 640; + const height = splashScreenOptions?.height ?? 480; + + // determine the bounds of the screen on which Theia will be shown + const lastWindowOptions = this.getLastWindowOptions(); + const defaultWindowBounds = this.getDefaultTheiaWindowBounds(); + const theiaPoint = typeof lastWindowOptions.x === 'number' && typeof lastWindowOptions.y === 'number' ? + { x: lastWindowOptions.x, y: lastWindowOptions.y } : + { x: defaultWindowBounds.x!, y: defaultWindowBounds.y! }; + const { bounds } = screen.getDisplayNearestPoint(theiaPoint); + + // place splash screen center of screen + const middlePoint = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; + const x = middlePoint.x - width / 2; + const y = middlePoint.y - height / 2; + + return { + x, y, width, height + }; + } + protected showInitialWindow(): void { - if (this.config.electron.showWindowEarly && + if (this.isShowWindowEarly() && !('THEIA_ELECTRON_NO_EARLY_WINDOW' in process.env && process.env.THEIA_ELECTRON_NO_EARLY_WINDOW === '1')) { console.log('Showing main window early'); app.whenReady().then(async () => { - const options = await this.getLastWindowOptions(); + const options = this.getLastWindowOptions(); this.initialWindow = await this.createWindow({ ...options }); this.initialWindow.show(); }); } } + protected isShowWindowEarly(): boolean { + return typeof this.config.electron.showWindowEarly === 'boolean' && this.config.electron.showWindowEarly; + } + + protected isShowSplashScreen(): boolean { + return typeof this.config.electron.showWindowEarly === 'object' && !!this.config.electron.showWindowEarly.content; + } + + protected getSplashScreenOptions(): SplashScreenOptions | undefined { + if (this.isShowSplashScreen()) { + return this.config.electron.showWindowEarly as SplashScreenOptions; + } + return undefined; + } + /** * Use this rather than creating `BrowserWindow` instances from scratch, since some security parameters need to be set, this method will do it. * @@ -319,7 +401,7 @@ export class ElectronMainApplication { return electronWindow.window; } - async getLastWindowOptions(): Promise { + getLastWindowOptions(): TheiaBrowserWindowOptions { const previousWindowState: TheiaBrowserWindowOptions | undefined = this.electronStore.get('windowstate'); const windowState = previousWindowState?.screenLayout === this.getCurrentScreenLayout() ? previousWindowState @@ -365,6 +447,7 @@ export class ElectronMainApplication { preload: path.resolve(this.globals.THEIA_APP_PROJECT_PATH, 'lib', 'frontend', 'preload.js').toString() }, ...this.config.electron?.windowOptions || {}, + preventAutomaticShow: this.isShowSplashScreen() }; } @@ -376,20 +459,44 @@ export class ElectronMainApplication { } protected async openWindowWithWorkspace(workspacePath: string): Promise { - const options = await this.getLastWindowOptions(); + const options = this.getLastWindowOptions(); const [uri, electronWindow] = await Promise.all([this.createWindowUri(), this.reuseOrCreateWindow(options)]); electronWindow.loadURL(uri.withFragment(encodeURI(workspacePath)).toString(true)); return electronWindow; } protected async reuseOrCreateWindow(asyncOptions: MaybePromise): Promise { - if (!this.initialWindow) { - return this.createWindow(asyncOptions); - } + const windowPromise = this.initialWindow ? Promise.resolve(this.initialWindow) : this.createWindow(asyncOptions); // reset initial window after having it re-used once - const window = this.initialWindow; this.initialWindow = undefined; - return window; + + // hook ready listener to dispose splash screen as configured via min and maximum wait times + if (this.splashScreenState) { + windowPromise.then(window => { + TheiaRendererAPI.onApplicationStateChanged(window.webContents, state => { + if (state === 'ready') { + this.splashScreenState?.minTime.then(() => { + // sanity check (e.g. max time < min time) + if (this.splashScreenState) { + window.show(); + this.splashScreenState.splashScreenWindow?.close(); + this.splashScreenState = undefined; + } + }); + } + }); + this.splashScreenState?.maxTime.then(() => { + // check whether splash screen was already disposed + if (this.splashScreenState?.splashScreenWindow) { + window.show(); + this.splashScreenState.splashScreenWindow?.close(); + this.splashScreenState = undefined; + } + }); + }); + } + + return windowPromise; } /** Configures native window creation, i.e. using window.open or links with target "_blank" in the frontend. */ diff --git a/packages/core/src/electron-main/theia-electron-window.ts b/packages/core/src/electron-main/theia-electron-window.ts index 17c82fa9a4511..985fc01220fbc 100644 --- a/packages/core/src/electron-main/theia-electron-window.ts +++ b/packages/core/src/electron-main/theia-electron-window.ts @@ -37,6 +37,12 @@ export interface TheiaBrowserWindowOptions extends BrowserWindowConstructorOptio * in which case we want to invalidate the stored options and use the default options instead. */ screenLayout?: string; + /** + * By default, the window will be shown as soon as the content is ready to render. + * This can be prevented by handing over preventAutomaticShow: `true`. + * Use this for fine-grained control over when to show the window, e.g. to coordinate with a splash screen. + */ + preventAutomaticShow?: boolean; } export const TheiaBrowserWindowOptions = Symbol('TheiaBrowserWindowOptions'); @@ -76,7 +82,9 @@ export class TheiaElectronWindow { protected init(): void { this._window = new BrowserWindow(this.options); this._window.setMenuBarVisibility(false); - this.attachReadyToShow(); + if (!this.options.preventAutomaticShow) { + this.attachReadyToShow(); + } this.restoreMaximizedState(); this.attachCloseListeners(); this.trackApplicationState();