Move useWindowInitData out of the single-file src/renderer/core/ tree into the canonical src/renderer/hooks/ bucket and remove the now-empty core/ directory. The renderer core/ tree was pre-created to mirror src/main/core/ but only ever held this one cross-window hook, violating the naming convention against pre-creating subdirectories for anticipated growth. Update all three import sites plus the doc and comment references (windows/README, preload comment, window-manager README and usage guide) to the new path.
11 KiB
WindowManager Usage Guide
Practical guide for using WindowManager from consumer code. For architectural context, see Overview. For full method reference, see API Reference.
Quick Start
1. Add the WindowType enum value
In types.ts:
export enum WindowType {
Main = 'main',
// ... existing types
Settings = 'settings', // <-- add your new type
}
2. Register in the window registry
In windowRegistry.ts:
WINDOW_TYPE_REGISTRY[WindowType.Settings] = {
type: WindowType.Settings,
lifecycle: 'singleton',
htmlPath: 'settings.html',
// preload omitted → defaults to 'index.js'
// showMode omitted → defaults to 'auto'
windowOptions: {
...DEFAULT_WINDOW_CONFIG,
width: 800,
height: 600,
minWidth: 600,
minHeight: 400,
},
}
3. Open the window
import { application } from '@application'
import { WindowType } from '@main/core/window/types'
const wm = application.get('WindowManager')
// open() is lifecycle-aware — handles singleton reuse, pool recycle, etc.
const windowId = wm.open(WindowType.Settings)
4. Inject domain behavior via onWindowCreatedByType
// In your domain service's onInit():
const wm = application.get('WindowManager')
wm.onWindowCreatedByType(WindowType.Settings, ({ window, id }) => {
// Store the windowId for later use
this.settingsWindowId = id
// Attach event listeners BEFORE content loads
window.on('closed', () => {
this.settingsWindowId = undefined
})
})
The example above uses destructuring. An equivalent using the mw shorthand (useful when the callback body is long or accesses many fields):
wm.onWindowCreatedByType(WindowType.Settings, (mw) => {
this.settingsWindowId = mw.id
mw.window.on('closed', () => { this.settingsWindowId = undefined })
})
Both are valid — see Callback styles for when to prefer which.
Domain Service Integration
The onWindowCreated event is the canonical hook for domain services to inject window-specific behavior, and pairs with wm.open() / wm.close() as the universal consumer API. For single-type subscriptions (the typical case) prefer the onWindowCreatedByType / onWindowDestroyedByType convenience variants — they filter by type for you so the callback body focuses on behavior, not guards.
The Pattern
@Injectable('SettingsService')
@ServicePhase(Phase.WhenReady)
export class SettingsService extends BaseService {
private settingsWindowId: string | undefined
protected override onInit(): void {
const wm = application.get('WindowManager')
wm.onWindowCreatedByType(WindowType.Settings, ({ window, id }) => {
// 1. Store the windowId
this.settingsWindowId = id
// 2. Attach listeners BEFORE content loads
window.once('ready-to-show', () => {
this.sendInitialConfig(window)
})
window.on('closed', () => {
this.settingsWindowId = undefined
})
})
wm.onWindowDestroyedByType(WindowType.Settings, () => {
this.settingsWindowId = undefined
})
}
}
Injecting behavior: onWindowCreated is the canonical hook
Domain services attach window-specific behavior inside an onWindowCreated subscription. This pairs with wm.open() as the universal consumer API: open() produces or reuses a window according to its registry lifecycle, and onWindowCreated fires exactly once per fresh BrowserWindow instance. You never need to branch on "new vs reused" at the call site.
For subscriptions that only care about a single window type (the typical consumer case), use the onWindowCreatedByType(type, listener) / onWindowDestroyedByType(type, listener) convenience variants — they apply the type filter for you, so the callback body never starts with if (managed.type !== X) return. The generic onWindowCreated / onWindowDestroyed remain available for the rare "observe all windows" use case.
What onWindowCreated gives you for free:
- Fires exactly once per fresh BrowserWindow. Singleton reopens and pool recycles do NOT re-fire — so listeners attached here never accumulate duplicates, and
open()is always safe regardless of reuse path. - Covers every
open()call site with one subscription. Primary path, crash recovery, test fixtures, and any future-added entry point all flow through the same event. You cannot forget to wire up a new path. - Fires before
loadURL. Pre-load configuration such assetFocusable(Linux Wayland),setContentProtection, orwebContentssession setup can be applied in time to affect first paint. - Works for pooled windows too. Per-instance listeners like
resizedorclosedmust be attached here — the recycle path does not re-fire the event, so attaching them at anopen()call site would either miss the recycled instance or accumulate on re-open.
Anti-pattern: direct-ID attachment at the open() call site.
It's tempting to attach listeners inline after wm.open() returns, since the ID is right there:
const id = wm.open(WindowType.Settings)
const window = wm.getWindow(id)!
window.on('blur', this.hideIfUnpinned)
window.once('closed', () => { this.windowId = null })
This looks cleaner than subscribing to an event, but it carries three hidden costs:
- Forces you off
open(). If the window is reused (singleton reopen or pool recycle), these listeners attach a second time on a window that already has them. To make the pattern safe you'd have to switch tocreate()— which is an internal primitive, not a consumer API (see "Window API layers" below). - Multiple entry paths silently decouple. Crash recovery, test fixtures, or any future
open()call site each need to remember to run setup. AnonWindowCreatedsubscription covers all of them in one place. - Implicit coupling to registry config. If listener safety depends on a specific
showMode/paintWhenInitiallyHidden/ etc. value (e.g. pre-showsetFocusabletiming that only works whenshowMode: 'manual'), a later registry change breaks correctness with no compile-time signal.
If you feel drawn to this pattern, subscribe to onWindowCreatedByType(type, listener) — one extra line, and all three costs disappear.
Callback styles: destructuring vs mw shorthand
The onWindowCreatedByType / onWindowDestroyedByType listeners receive a ManagedWindow — the same record shape as the generic variants. Two idiomatic ways to access its fields:
Destructuring (recommended default, short callback):
wm.onWindowCreatedByType(WindowType.Settings, ({ window, id }) => {
this.settingsWindowId = id
window.on('closed', () => { this.settingsWindowId = undefined })
})
Pull exactly the fields you need out of the parameter — { window }, { window, id }, { window, id, metadata }. Self-documenting and avoids the mw.window.on(...) visual noise.
mw shorthand (callback with inner closures or many accesses):
wm.onWindowCreatedByType(WindowType.SelectionAction, (mw) => {
// Inner closure reads mw.window's methods repeatedly — keeping the whole
// record under one short name reads better than re-destructuring.
mw.window.on('resized', () => {
if (mw.window.isDestroyed()) return
this.saveBounds(mw.id, mw.window.getBounds())
})
})
mw is the initials of ManagedWindow — short, specific, and doesn't collide with the .window field the way a parameter named window would.
Pick whichever reads better in context. Mixing them across files — or even within the same service — is fine; the parameter name is the only difference.
Window API layers: consumer vs internal
WindowManager exposes four lifecycle methods, arranged in two layers:
| Layer | Method | Semantics | When to call |
|---|---|---|---|
| Consumer | open(type, args?) |
Lifecycle-aware: fresh create, singleton reuse, or pool recycle per registry | Always, to obtain a window |
| Consumer | close(windowId) |
Lifecycle-aware: destroy non-pooled, release-to-pool for pooled | Always, to release a window |
| Internal | create(type, args?) |
Force fresh creation; throws if singleton already exists | Defensive assertion — consumer code should not need it |
| Internal | destroy(windowId) |
Force destroy; bypasses pool recycling | Not needed in consumer code (see below) |
Consumer code should only ever call open() and close(). The registry's lifecycle declaration is the single source of truth for how those methods behave, so call sites do not need to branch on window type.
Why create() is not a consumer API. Every common motivation for reaching for create() has a cleaner open()-based resolution:
| Urge | Resolution |
|---|---|
| "I need my setup to run only on fresh windows" | Subscribe to onWindowCreatedByType — it fires only on fresh, never on reuse |
| "I need to be sure no duplicate singleton exists" | Registry lifecycle: 'singleton' already guarantees it; open() returns the existing instance |
"My service's local windowId must match WindowManager's" |
Subscribe to onWindowDestroyedByType to clear local state in sync with WM's 'closed' tracking |
Why destroy() is not a consumer API. On non-pooled windows (default and singleton) close() falls through to the same destroyWindow() call — there is no behavioral difference. On pooled windows, destroy() bypasses the pool, which is almost never what a consumer actually wants; the correct API for "stop the whole pool" is suspendPool(type), which destroys idle windows and prevents further recycling without touching in-use windows.
Domain-Key-to-WindowId Mapping
For window types that are keyed by domain data (e.g., a topic-specific window), the domain service maintains its own mapping:
// Domain service tracks which topic is shown in which window
private topicWindows = new Map<string, string>() // topicId -> windowId
wm.onWindowCreatedByType(WindowType.TopicView, ({ id }) => {
const topicId = wm.getInitData(id) as string
this.topicWindows.set(topicId, id)
})
// Open a topic — reuse existing or create new
openTopic(topicId: string): void {
const existingId = this.topicWindows.get(topicId)
if (existingId) {
wm.show(existingId)
wm.focus(existingId)
return
}
const windowId = wm.open(WindowType.TopicView, { initData: topicId })
}
Renderer: useWindowInitData hook
src/renderer/hooks/useWindowInitData.ts provides the canonical way for any managed window to consume its init data across both creation paths:
import { useWindowInitData } from '@renderer/hooks/useWindowInitData'
const MyWindowApp: FC = () => {
const data = useWindowInitData<MyInitData>()
if (!data) return null
return <ControlledContent data={data} />
}
- On mount: pulls via
WindowManager_GetInitDatainvoke (cold-start path). - On re-use: receives the
WindowManager_Reusedpayload (PUSH path, zero round-trip). - Per-session state resets should live inside the child component in
useEffect([data.someStableId], …), so the DOM stays continuous across recycles — never usekey={resetKey}to forcibly remount; that reintroduces the flash this contract was designed to eliminate.
For the full cold-start vs reuse timing contract, see Init Data in the API Reference.