Skip to content
89 changes: 86 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,51 @@ await App.addListener('appUrlOpen', ({ url }) => {

Deep links opened while the app is running are routed to the running instance (single instance is enforced by default); the URL that launched the app is available via `App.getLaunchUrl()`.

### Splash Screen

Booting a desktop app is not instant: the platform runs every plugin's `load()` lifecycle hook (e.g. the [Live Update](https://capawesome.io/plugins/live-update/) plugin verifying and activating a bundle) _before_ the main window is shown. A splash screen covers that gap so the app never appears frozen or blank.

A splash screen is shown automatically when a splash file exists in the electron app directory — no configuration required. Two files are looked up, in order:

1. `electron/assets/splash.html`
2. `electron/assets/splash.png`

The scaffold ships a neutral, theme-aware `assets/splash.html` by default. Migrating from [`@capacitor-community/electron`](https://github.com/capacitor-community/electron)? Its `assets/splash.png` is picked up unchanged.

Configure the splash screen in `electron/capacitor.electron.config.ts`:

```typescript
import { defineConfig } from '@capawesome/capacitor-electron/config';

export default defineConfig({
splashScreen: {
// Custom file, relative to the electron app directory. Either an `.html`
// file or an image (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`).
path: 'assets/splash.html',
width: 400,
height: 300,
backgroundColor: '#ffffff',
// Keep the splash visible for at least this long, even on fast startups.
minimumDurationMs: 0,
},
});
```

| Option | Type | Default | Description |
| ------------------- | --------- | ----------------------------------------- | ---------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | shown when a splash file exists | Set `false` to disable. Set `true` to require a splash file — boot fails if none is found. |
| `path` | `string` | `assets/splash.html`, `assets/splash.png` | Splash file relative to the electron app directory. |
| `width` | `number` | `400` | Window width in pixels. |
| `height` | `number` | `300` | Window height in pixels. |
| `backgroundColor` | `string` | `'#ffffff'` | Window background and the canvas behind an image splash. |
| `minimumDurationMs` | `number` | `0` | Minimum time the splash stays visible. |

**HTML vs. image:** an `.html` file is loaded directly, so you get full control over layout, fonts, and animation. An image is centered (`object-fit: contain`) on a `backgroundColor` canvas — convenient for a logo, but static.

The splash window is deliberately kept outside the plugin bridge: it is frameless, sandboxed, has no preload and no access to the app scheme, and navigation is blocked.

> **Packaging note:** the splash files live under `assets/`, which is also the electron-builder `buildResources` directory. The scaffolded `electron-builder.config.js` includes `assets/**/*` in `files` so the splash ships inside the packaged app (`app.asar`). If you replace the config, keep that entry — otherwise the splash works in development but silently disappears from packaged binaries.

### Debugging

The platform keeps Electron's default application menu, so the Chromium DevTools can be opened at any time via _View → Toggle Developer Tools_ or the keyboard shortcut:
Expand Down Expand Up @@ -250,6 +295,7 @@ If you prefer **Manual Migration**, perform the following steps:
Notes:

- Deep links no longer require hand-written runtime code — declare the scheme in the platform config and listen to `@capacitor/app`'s `appUrlOpen` event.
- Splash screens are picked up automatically from `electron/assets/`. Keep `assets/splash.png` and it just works; if you used a custom `splashScreenImageName: 'x.gif'`, either rename it to `assets/splash.png` or point the config at it via `splashScreen: { path: 'assets/x.gif' }` (see [Splash Screen](#splash-screen)).
- Plugins must provide an electron implementation for this platform's contract (see [Plugin Development](#plugin-development)); implementations written for the old platform are not loaded. Plugins whose web implementation is sufficient continue to work unchanged via the automatic fallback.

## Plugin Development
Expand All @@ -264,20 +310,53 @@ Plugins declare their electron implementation via `package.json`:
}
```

The implementation is an ES module at `<src>/dist/plugin.mjs` exporting plugin classes. A plugin class declares its Capacitor registration name and its public API via static metadata — the static property is the contract, so no build-time dependency on this package is required:
The implementation is an ES module at `<src>/dist/plugin.mjs` exporting plugin classes. A plugin class declares its Capacitor registration name and its public API via static metadata — the static property is the contract, so a build-time dependency on this package is not required.

### Recommended: extend `ElectronPlugin`

Mirroring how Android/iOS plugins extend Capacitor's `Plugin` and override `load()`, the recommended path is to extend the `ElectronPlugin` base class. Add `@capawesome/capacitor-electron` as a **devDependency** (for the types) and an **optional peerDependency** (for the runtime value), then:

```ts
import { ElectronPlugin, defineElectronPlugin } from '@capawesome/capacitor-electron/plugin';

class SqliteImpl extends ElectronPlugin {
// `this.context` (config, services, notifyListeners) is stored by the base constructor.

// Optional lifecycle hook. Awaited by the platform before the first window loads.
async load() { ... }

async open(options) { ... }
async query(options) { ... }

// Not declared below, therefore never bridged.
resolvePath(path) { ... }
}

export const Sqlite = defineElectronPlugin(
{ name: 'Sqlite', methods: ['open', 'query'] },
SqliteImpl,
);
```

### Zero-dependency: marker-only

The base class is optional sugar — the discovery contract is the static `__capacitorElectronPlugin` metadata, and the lifecycle hook is detected structurally (never via `instanceof`, which would break across duplicated copies of this package). So a plugin can ship with **no dependency on this package at all**, implementing a structural `load()` if it needs the hook:

```ts
class SqliteImpl {
constructor({ config, services, notifyListeners }) { ... }

// Optional. Structural lifecycle hook, detected by name.
async load() { ... }

async open(options) { ... }
async query(options) { ... }

// Not declared below, therefore never bridged.
resolvePath(path) { ... }
}

// Equivalent: import { defineElectronPlugin } from '@capawesome/capacitor-electron/plugin';
// Equivalent to defineElectronPlugin, without importing this package.
SqliteImpl.__capacitorElectronPlugin = {
name: 'Sqlite',
methods: ['open', 'query'],
Expand All @@ -288,12 +367,16 @@ export { SqliteImpl as Sqlite };

The declared `methods` array is the plugin's entire bridged surface: anything not listed stays main-process-internal, and a declared method that is missing on the class fails loudly at boot. Each class is instantiated once in the main process (full Node and Electron API access) and exposed under its registration name through Capacitor's native plugin path — `registerPlugin('Sqlite', { web: ... })` just works, with the web implementation as the automatic fallback for platforms the plugin doesn't cover. No `electron` key in the plugin's `registerPlugin` wiring is needed.

The constructor context provides:
The constructor context (`this.context` on an `ElectronPlugin` subclass, or the constructor argument otherwise) provides:

- `config` — the app's Capacitor configuration.
- `notifyListeners(eventName, data)` — emits a plugin event, mirroring Capacitor's native `notifyListeners`. Web listeners use the standard `addListener(eventName, callback)` / `PluginListenerHandle` API.
- `services` — platform primitives (currently `services.bundles`: web-bundle serving, reload, and the failed-boot rollback watchdog).

### The `load()` lifecycle hook

`load()` runs once after the plugin is constructed and is **awaited before the first application window loads**, so async setup — including repointing the active bundle via `services.bundles.setActiveBundle()` — takes effect on first paint (no default-bundle flash). It is a lifecycle hook, **not** a bridged method: `load` is reserved and is never bridged to the renderer. It must **not** be listed in `methods` — doing so is rejected at boot, because bridging it would let web content invoke the lifecycle hook arbitrarily. A rejected or thrown `load()` fails the app boot loudly, the same way a declared-but-missing method does. On an `ElectronPlugin` subclass the default `load()` is a no-op, so overriding it is optional.

At sync time the platform statically scans the app's dependencies and generates a plugin manifest — no plugin code runs outside Electron. Results, thrown `Error`s, and their `code` properties cross the bridge with Capacitor semantics.

## Packaging
Expand Down
4 changes: 4 additions & 0 deletions example/electron/electron-builder.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ module.exports = {
'build/**/*',
'app/**/*',
'generated/**/*',
// `assets` is also the electron-builder `buildResources` directory, whose
// contents are NOT packaged by default. Include it explicitly so the
// splash screen (and any other runtime assets) ship in the app.
'assets/**/*',
'package.json',
// Platform runtime + plugins, prepared by `capacitor-electron vendor`.
{ from: 'vendor/node_modules', to: 'node_modules' },
Expand Down
2 changes: 1 addition & 1 deletion src/cli/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from 'path';
import type { CliContext } from './context';
import { logInfo, fail } from './log';

const TEXT_EXTENSIONS = ['.ts', '.js', '.json', '.md'];
const TEXT_EXTENSIONS = ['.ts', '.js', '.json', '.md', '.html'];

const slugify = (value: string): string =>
value
Expand Down
49 changes: 49 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,54 @@ export interface ElectronContentSecurityPolicyOptions {
devPolicy?: string;
}

export interface ElectronSplashScreenOptions {
/**
* Whether the splash screen is shown while the app boots.
*
* When unset, the splash screen is shown only if a splash file is found
* (`splash.html` or `assets/splash.png` relative to the electron app
* directory, or the file referenced by `path`). Set to `true` to require a
* splash file — boot fails loudly if none resolves. Set to `false` to
* disable the splash screen entirely.
Comment thread
Copilot marked this conversation as resolved.
*/
enabled?: boolean;
/**
* Path to the splash screen file, relative to the electron app directory.
* Either an HTML file (`.html`) or an image
* (`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`). Images are centered
* on a `backgroundColor` canvas.
*
* When unset, `splash.html` and then `assets/splash.png` are tried.
Comment thread
Copilot marked this conversation as resolved.
Outdated
*/
path?: string;
/**
* Width of the splash screen window in pixels.
*
* @default 400
*/
width?: number;
/**
* Height of the splash screen window in pixels.
*
* @default 300
*/
height?: number;
/**
* Background color of the splash screen window (and the image canvas).
*
* @default '#ffffff'
*/
backgroundColor?: string;
/**
* Minimum duration in milliseconds the splash screen stays visible, even
* if the app finishes booting sooner. Prevents a jarring flash on fast
* startups.
*
* @default 0
*/
minimumDurationMs?: number;
}

export interface ElectronDeepLinksOptions {
/**
* Custom URL scheme to register with the operating system, e.g. `myapp`
Expand Down Expand Up @@ -82,6 +130,7 @@ export interface CapacitorElectronConfig {
*/
hostname?: string;
window?: ElectronWindowOptions;
splashScreen?: ElectronSplashScreenOptions;
csp?: ElectronContentSecurityPolicyOptions;
deepLinks?: ElectronDeepLinksOptions;
/**
Expand Down
81 changes: 76 additions & 5 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,27 @@ export interface BundlesService {
/**
* Repoint the serving protocol to the given bundle directory and reload
* all app windows. Pass `null` to revert to the packaged app bundle.
* The renderer must call `notifyBootReady()` within the watchdog timeout,
* otherwise the previous bundle is restored.
*
* By default the failed-boot rollback watchdog is armed: the renderer must
* call `notifyBootReady()` within the watchdog timeout, otherwise the
* previous bundle is restored.
*
* Pass `{ bootWatchdog: false }` to opt out of the watchdog for this
* activation. No pending marker is persisted (so the startup pending-check
* never reverts the bundle), the watchdog timer is not armed, and
* `notifyBootReady()` becomes a no-op for this activation. Use this when
* the caller owns rollback itself (e.g. a live-update engine with its own
* kill-safe state machine); running both watchdogs would drift the two
* persisted states.
*/
setActiveBundle(bundleDirectory: string | null): Promise<void>;
setActiveBundle(
bundleDirectory: string | null,
options?: { bootWatchdog?: boolean },
): Promise<void>;
/**
* Signal that the newly served bundle booted successfully, cancelling the
* failed-boot rollback watchdog.
* failed-boot rollback watchdog. A no-op for activations made with
* `{ bootWatchdog: false }`.
*/
notifyBootReady(): void;
}
Expand Down Expand Up @@ -58,13 +72,70 @@ export interface ElectronPluginMetadata {
* The plugin's public API: the methods exposed to the web app. Methods
* not listed here are never bridged. Each declared method must exist on
* the class prototype (validated at boot).
*
* `load` is reserved for the lifecycle hook (see
* {@link ElectronPluginLifecycle}) and must NOT be listed here — doing so
* is rejected at boot, because bridging it would let the renderer invoke
* the lifecycle hook arbitrarily.
*/
methods: string[];
}

/**
* Optional lifecycle contract a plugin instance may implement — the
* structural counterpart of the {@link ElectronPlugin} base class.
*
* `load` runs once after the plugin is constructed and is awaited by the
* platform before the first application window loads, so a plugin can
* perform async setup (e.g. repointing the active bundle via
* `services.bundles`) and have it take effect on first paint. It is a
* lifecycle hook, NOT a bridged method: `load` is reserved and is never
* bridged to the renderer. It must NOT be listed in the static metadata's
* `methods` — doing so is rejected at boot. A rejected/thrown `load` fails
* the app boot loudly.
*/
export interface ElectronPluginLifecycle {
load?(): Promise<void> | void;
}

/**
* Recommended base class for electron plugin implementations, mirroring how
* Android/iOS plugins extend Capacitor's `Plugin` and override `load()`.
*
* Extending it is optional — the discovery contract is the static
* {@link ELECTRON_PLUGIN_MARKER} metadata (see {@link defineElectronPlugin}),
* not this class, and the platform never uses `instanceof` to detect plugins
* (that would break across duplicated copies of this package in
* `node_modules`). It provides the ergonomic, typed path: the constructor
* stores the {@link ElectronPluginContext} and {@link load} is an overridable
* lifecycle hook with a no-op default.
*
* To adopt it, add `@capawesome/capacitor-electron` as a devDependency (for
* the types) and an optional peerDependency (for the runtime value).
*/
export class ElectronPlugin implements ElectronPluginLifecycle {
protected readonly context: ElectronPluginContext;

constructor(context: ElectronPluginContext) {
this.context = context;
}

/**
* Lifecycle hook, overridable by subclasses. Runs once after all plugins
* have been constructed and is awaited by the platform before the first
* application window loads, so async setup (e.g. repointing the active
* bundle via `context.services.bundles`) takes effect on first paint. A
* throwing/rejecting `load` aborts app boot. `load` is reserved and is
* never bridged to the renderer. The default implementation is a no-op.
*/
load(): Promise<void> | void {
// no-op default
}
}

export type ElectronPluginClass = (new (
context: ElectronPluginContext,
) => unknown) & {
) => ElectronPluginLifecycle | unknown) & {
[ELECTRON_PLUGIN_MARKER]?: ElectronPluginMetadata;
};

Expand Down
Loading
Loading