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