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();