From 8826c676398ff382b3684cef3ceef0ae5bd743d4 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.splashScreenOptions". Mandatory is the option "content" which specifies a relative path from the application root 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 | 37 ++++- examples/electron/package.json | 8 +- examples/electron/resources/theia-logo.svg | 32 ++++ .../electron-main-application.ts | 153 ++++++++++++++++-- .../electron-main/theia-electron-window.ts | 10 +- 6 files changed, 228 insertions(+), 15 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..91de09fc319b7 100644 --- a/dev-packages/application-package/src/application-props.ts +++ b/dev-packages/application-package/src/application-props.ts @@ -33,8 +33,36 @@ export type ElectronFrontendApplicationConfig = RequiredRecursive + + + + + + + + + + + + + + + diff --git a/packages/core/src/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 2efa9fce419e2..dda80aa3b4149 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; @@ -182,7 +192,10 @@ export class ElectronMainApplication { protected windows = new Map(); protected restarting = false; + /** Used to temporarily store the reference to an early created main window, in case the app is configured with `showWindowEarly` and without `splashScreenOptions` */ protected initialWindow?: BrowserWindow; + /** Used to temporarily store the splash screen state, in case one is rendered. Will be reset once splash screen handling is over. */ + protected splashScreenState?: SplashScreenState; get config(): FrontendApplicationConfig { if (!this._config) { @@ -224,6 +237,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 +301,102 @@ 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 + console.error('Splash screen options not available although they should be. Will not show a splash screen.'); + return; + } + const content = splashScreenOptions.content; + console.debug('SplashScreen options', splashScreenOptions); + // indicate to render a splash screen + this.splashScreenState = {}; + app.whenReady().then(() => { + this.determineSplashScreenBounds().then(splashScreenBounds => { + const splashScreenWindow = new BrowserWindow({ + ...splashScreenBounds, + frame: false, + alwaysOnTop: true, + show: this.isShowWindowEarly() + }); + this.splashScreenState = { + splashScreenWindow, + minTime: new Promise(resolve => setTimeout(() => resolve(), splashScreenOptions.minDuration ?? 0)), + maxTime: new Promise(resolve => setTimeout(() => resolve(), splashScreenOptions.maxDuration ?? 30000)), + }; + if (!this.isShowWindowEarly()) { + splashScreenWindow.on('ready-to-show', () => { + this.splashScreenState?.splashScreenWindow?.show(); + }); + } + splashScreenWindow.loadFile(path.resolve(this.globals.THEIA_APP_PROJECT_PATH, content).toString()); + }); + }); + } + } + + protected async determineSplashScreenBounds(): Promise<{ 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 Theia main application + const lastWindowOptions = await this.getLastWindowOptions(); + const defaultWindowBounds = this.getDefaultTheiaWindowBounds(); + const theiaBounds = typeof lastWindowOptions.x === 'number' && + typeof lastWindowOptions.y === 'number' && + typeof lastWindowOptions.width === 'number' && + typeof lastWindowOptions.height === 'number' ? + { x: lastWindowOptions.x, y: lastWindowOptions.y, width: lastWindowOptions.width, height: lastWindowOptions.height } : + { x: defaultWindowBounds.x!, y: defaultWindowBounds.y!, width: lastWindowOptions.width!, height: lastWindowOptions.height! }; + + // determine the screen on which to show the splash screen via the center of the Theia window to show + const theiaCenterPoint = { x: theiaBounds.x + theiaBounds.width / 2, y: theiaBounds.y + theiaBounds.height / 2 }; + const { bounds } = screen.getDisplayNearestPoint(theiaCenterPoint); + + // 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 isShowWindowEarly(): boolean { + return !!this.config.electron.showWindowEarly && + !('THEIA_ELECTRON_NO_EARLY_WINDOW' in process.env && process.env.THEIA_ELECTRON_NO_EARLY_WINDOW === '1'); + } + protected showInitialWindow(): void { - if (this.config.electron.showWindowEarly && - !('THEIA_ELECTRON_NO_EARLY_WINDOW' in process.env && process.env.THEIA_ELECTRON_NO_EARLY_WINDOW === '1')) { + if (this.isShowWindowEarly() && !this.isShowSplashScreen()) { 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 isShowSplashScreen(): boolean { + return typeof this.config.electron.splashScreenOptions === 'object' && !!this.config.electron.splashScreenOptions.content; + } + + protected getSplashScreenOptions(): SplashScreenOptions | undefined { + if (this.isShowSplashScreen()) { + return this.config.electron.splashScreenOptions 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. * @@ -307,6 +405,10 @@ export class ElectronMainApplication { async createWindow(asyncOptions: MaybePromise = this.getDefaultTheiaWindowOptions()): Promise { let options = await asyncOptions; options = this.avoidOverlap(options); + if (this.splashScreenState) { + // in case we show a splash screen, do not automatically open created windows + options = { ...options, preventAutomaticShow: true }; + } const electronWindow = this.windowFactory(options, this.config); const id = electronWindow.window.webContents.id; this.windows.set(id, electronWindow); @@ -316,9 +418,40 @@ export class ElectronMainApplication { electronWindow.window.on('focus', () => TheiaRendererAPI.sendWindowEvent(electronWindow.window.webContents, 'focus')); this.attachSaveWindowState(electronWindow.window); this.configureNativeSecondaryWindowCreation(electronWindow.window); + this.configureShowOnSplashScreenClose(electronWindow.window); + return electronWindow.window; } + /** + * In case we show a splash screen, this will configure the window to show and the splash screen to close + * depending on the frontend ready state and user configuration. + */ + protected configureShowOnSplashScreenClose(window: BrowserWindow): void { + if (this.splashScreenState) { + const showWindowAndCloseSplashScreen = () => { + if (!window.isVisible()) { + window.show(); + } + this.splashScreenState?.splashScreenWindow?.close(); + this.splashScreenState = undefined; + }; + + TheiaRendererAPI.onApplicationStateChanged(window.webContents, state => { + if (state === 'ready') { + if (this.splashScreenState?.minTime) { + this.splashScreenState.minTime.then(() => showWindowAndCloseSplashScreen()); + } else { + showWindowAndCloseSplashScreen(); + } + } + }); + // maxTime should always exist here, sanity fallback + const maxTime = this.splashScreenState.maxTime ?? new Promise(resolve => setTimeout(() => resolve(), 30000)); + maxTime.then(() => showWindowAndCloseSplashScreen()); + } + } + async getLastWindowOptions(): Promise { const previousWindowState: TheiaBrowserWindowOptions | undefined = this.electronStore.get('windowstate'); const windowState = previousWindowState?.screenLayout === this.getCurrentScreenLayout() @@ -364,7 +497,7 @@ export class ElectronMainApplication { nodeIntegrationInWorker: false, preload: path.resolve(this.globals.THEIA_APP_PROJECT_PATH, 'lib', 'frontend', 'preload.js').toString() }, - ...this.config.electron?.windowOptions || {}, + ...this.config.electron?.windowOptions || {} }; } @@ -376,20 +509,18 @@ 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; + + 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();