Skip to content

Commit

Permalink
feat: splash screen support for Electron
Browse files Browse the repository at this point in the history
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 frontend
location to the content of the splash screen. Optionally "width",
"height", "minDuration" and "maxDuration" can be handed over too.

Implements eclipse-theia#13410
  • Loading branch information
sdirix committed Mar 19, 2024
1 parent 9ff0ced commit 6c68232
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 13 deletions.
40 changes: 37 additions & 3 deletions dev-packages/application-package/src/application-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,37 @@ export type ElectronFrontendApplicationConfig = RequiredRecursive<ElectronFronte
export namespace ElectronFrontendApplicationConfig {
export const DEFAULT: ElectronFrontendApplicationConfig = {
windowOptions: {},
showWindowEarly: true
showWindowEarly: true,
splashScreenOptions: {}
};
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.
* Content ending in ".js" will be handed over as preload script.
* Any other content will be loaded as file.
*
* Mandatory attribute.
*/
content?: string;
}
export interface Partial {

/**
Expand All @@ -45,11 +74,16 @@ export namespace ElectronFrontendApplicationConfig {
readonly windowOptions?: BrowserWindowConstructorOptions;

/**
* Whether or not to show an empty Electron window as early as possible.
* Whether or not to show an empty Electron main window as early as possible.
*
* Defaults to `true`.
* Has no effect if `splashScreenOptions.content` is also given.
*/
readonly showWindowEarly?: boolean;

/**
* Configuration options for splash screen.
*/
readonly splashScreenOptions?: SplashScreenOptions;
}
}

Expand Down
109 changes: 100 additions & 9 deletions packages/core/src/electron-main/electron-main-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export class ElectronMainProcessArgv {

}

interface SplashScreenState {
splashScreenWindow?: BrowserWindow;
minTime: Promise<void>;
maxTime: Promise<void>;
}

export namespace ElectronMainProcessArgv {
export interface ElectronMainProcess extends NodeJS.Process {
readonly defaultApp: boolean;
Expand Down Expand Up @@ -184,6 +190,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.');
Expand Down Expand Up @@ -224,6 +232,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);
Expand Down Expand Up @@ -287,12 +296,69 @@ 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 {
// content must be handed over for splash screen to take effect
if (this.config.electron.splashScreenOptions?.content) {
console.log('Showing splash screen');
const splashScreenOptions = this.config.electron.splashScreenOptions;
const content = this.config.electron.splashScreenOptions.content;
console.debug('SplashScreen options', splashScreenOptions);
app.whenReady().then(() => {
const splashScreenBounds = this.determineSplashScreenBounds();
const splashScreenWindow = new BrowserWindow({
...splashScreenBounds,
frame: false,
alwaysOnTop: true,
webPreferences: {
preload: content.endsWith('.js') ? content : undefined
}
});
splashScreenWindow.show();
if (!content.endsWith('.js')) {
splashScreenWindow.loadFile(content);
}
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.config.electron.splashScreenOptions;
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 &&
!('THEIA_ELECTRON_NO_EARLY_WINDOW' in process.env && process.env.THEIA_ELECTRON_NO_EARLY_WINDOW === '1')) {
!('THEIA_ELECTRON_NO_EARLY_WINDOW' in process.env && process.env.THEIA_ELECTRON_NO_EARLY_WINDOW === '1') &&
!this.config.electron.splashScreenOptions?.content) {
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();
});
Expand All @@ -319,7 +385,7 @@ export class ElectronMainApplication {
return electronWindow.window;
}

async getLastWindowOptions(): Promise<TheiaBrowserWindowOptions> {
getLastWindowOptions(): TheiaBrowserWindowOptions {
const previousWindowState: TheiaBrowserWindowOptions | undefined = this.electronStore.get('windowstate');
const windowState = previousWindowState?.screenLayout === this.getCurrentScreenLayout()
? previousWindowState
Expand Down Expand Up @@ -365,6 +431,7 @@ export class ElectronMainApplication {
preload: path.resolve(this.globals.THEIA_APP_PROJECT_PATH, 'lib', 'frontend', 'preload.js').toString()
},
...this.config.electron?.windowOptions || {},
preventAutomaticShow: !!this.config.electron.splashScreenOptions
};
}

Expand All @@ -376,20 +443,44 @@ export class ElectronMainApplication {
}

protected async openWindowWithWorkspace(workspacePath: string): Promise<BrowserWindow> {
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<TheiaBrowserWindowOptions>): Promise<BrowserWindow> {
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. */
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/electron-main/theia-electron-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down

0 comments on commit 6c68232

Please sign in to comment.