Files
CherryHQ-cherry-studio/docs/references/lifecycle/lifecycle-migration-guide.md
fullex 87b2b64ba5 refactor(process-entry): rename main/preload entries to named files per naming §6.4
src/main/index.ts → main.ts and src/preload/index.ts → preload.ts, so the process entries stop occupying the barrel-reserved index filename (§6.4) without needing an exemption list. The rename is wired through electron.vite (main build.lib.entry — rollupOptions.input would bypass electron-vite's output-format detection — and the preload input key), package.json main (out/main/main.js), WindowManager's default preload filename, MainWindowService's webview preload path, and DbService's slow-query stack filter, which matches the bundled artifact name. preload.d.ts becomes globals.d.ts: sharing preload.ts's basename makes TypeScript drop the .d.ts as that file's presumed output declaration, and tsgo only applies its global Window augmentation when the declaration sorts before its importer, so the file keeps a distinct, alphabetically-early name. Entry-path mentions in code comments and reference docs follow.
2026-07-04 20:18:14 -07:00

337 lines
12 KiB
Markdown

# Lifecycle Migration Guide
This guide walks you through converting existing **infrastructure services** to the lifecycle system. Services that manage resources, require ordered initialization, or need cleanup belong here. Stateless business-logic services (repositories, data-access layers) should remain as simple singletons — see [Decision Guide](./lifecycle-decision-guide.md).
## Old Patterns You'll Encounter
### Pattern A: Manual Singleton
```typescript
// OLD — manual singleton + exported instance
class MainWindowService {
private static instance: MainWindowService | null = null
public static getInstance(): MainWindowService {
if (!MainWindowService.instance) {
MainWindowService.instance = new MainWindowService()
}
return MainWindowService.instance
}
init() { /* ... */ }
destroy() { /* ... */ }
}
export const windowService = MainWindowService.getInstance()
```
### Pattern B: Raw `new` Export
```typescript
// OLD — instantiated on import, init called manually
class ThemeService {
init() { /* ... */ }
}
export const themeService = new ThemeService()
```
### Pattern C: Free Functions
```typescript
// OLD — module-scoped state + exported function
let accelerator: string | null = null
export function registerShortcuts(mainWindow: BrowserWindow) { /* ... */ }
```
## Step-by-Step Migration
### Step 1: Extend BaseService and add decorators
Replace the class definition. Remove `static instance`, `getInstance()`, and `init()`/`destroy()` — the lifecycle system handles all of these.
```typescript
// NEW
import { BaseService, Injectable, ServicePhase, DependsOn, Phase } from '@main/core/lifecycle'
@Injectable('MainWindowService')
@ServicePhase(Phase.WhenReady) // needs Electron API → WhenReady
@DependsOn(['PreferenceService']) // reads preferences on startup
export class MainWindowService extends BaseService {
protected async onInit() {
// ← what was in init() / constructor logic
}
protected async onStop() {
// ← what was in destroy() / cleanup
}
}
```
**Choosing the right phase:** See [Phase Selection Guide](./lifecycle-overview.md#phase-selection-guide).
**Choosing error strategy:**
| Strategy | When to use |
| ----------- | ----------------------------------------------- |
| `graceful` | App can function without this service (default) |
| `fail-fast` | App cannot function (database, core config) |
### Step 2: Remove singleton boilerplate
Delete all of these:
```typescript
// DELETE all of the following
private static instance: MainWindowService | null = null
public static getInstance(): MainWindowService { ... }
// DELETE the exported instance
export const windowService = MainWindowService.getInstance()
// or
export const windowService = new MainWindowService()
```
The lifecycle container creates and manages the singleton automatically.
### Step 3: Register in serviceRegistry.ts
```typescript
// src/main/core/application/serviceRegistry.ts
import { MainWindowService } from '@main/services/MainWindowService'
export const services = {
// ...existing
MainWindowService, // ← one line
} as const
```
### Step 4: Replace all import sites
Find every file that imports the old singleton and update:
```typescript
// OLD
import { windowService } from '@main/services/MainWindowService'
windowService.createMainWindow()
// NEW
import { application } from '@application'
const windowService = application.get('MainWindowService')
windowService.createMainWindow()
```
> **Conditional services**: If the migrated service uses `@Conditional`, replace `application.get()` calls at import sites with `application.getOptional()`:
> ```typescript
> const menuService = application.getOptional('AppMenuService')
> menuService?.buildMenu()
> ```
### Step 5: Replace dependencies with `@DependsOn`
If the old service imported other service singletons at the top level, convert those to `@DependsOn` and access them via `application.get()` inside methods:
```typescript
// OLD — tight coupling via top-level import
import { windowService } from './MainWindowService'
class TrayService {
init() {
windowService.show()
}
}
// NEW — loose coupling via lifecycle
@Injectable('TrayService')
@DependsOn(['MainWindowService'])
export class TrayService extends BaseService {
protected async onInit() {
const windowService = application.get('MainWindowService')
windowService.show()
}
}
```
### Step 6: Remove manual init/destroy calls from main.ts
After migration, delete the manual calls in `src/main/main.ts`:
```typescript
// DELETE from index.ts
themeService.init()
windowService.createMainWindow()
new TrayService()
nodeTraceService.init()
analyticsService.init()
```
The lifecycle system calls `onInit()` automatically in the correct order during `application.bootstrap()`.
### Step 7: Migrate free functions to a service class
For Pattern C (free functions with module state), wrap them in a service:
```typescript
// OLD
let accelerator: string | null = null
export function registerShortcuts(mainWindow: BrowserWindow) { ... }
// NEW
@Injectable('ShortcutService')
@ServicePhase(Phase.WhenReady)
@DependsOn(['MainWindowService', 'PreferenceService'])
export class ShortcutService extends BaseService {
private accelerator: string | null = null
protected async onInit() {
this.registerShortcuts()
}
protected async onStop() {
globalShortcut.unregisterAll()
}
private registerShortcuts() { /* ... */ }
}
```
### Step 8: Migrate IPC handlers to BaseService tracking
If your service registers `ipcMain.handle()` or `ipcMain.on()` calls, replace them with `this.ipcHandle()` / `this.ipcOn()` and remove the manual unregister method:
```typescript
// OLD — channel appears twice (register + unregister)
private registerIpcHandlers(): void {
ipcMain.handle(IpcChannel.MyService_Action, (_, arg) => this.doAction(arg))
}
private unregisterIpcHandlers(): void {
ipcMain.removeHandler(IpcChannel.MyService_Action)
}
// NEW — auto-tracked, cleanup is automatic
private registerIpcHandlers(): void {
this.ipcHandle(IpcChannel.MyService_Action, (_, arg) => this.doAction(arg))
}
// DELETE unregisterIpcHandlers() entirely
```
Remove the `unregisterIpcHandlers()` method and its call from `onStop()`. BaseService cleans up all tracked handlers automatically after `onStop()` returns.
> **Tip**: `ipcHandle()` and `ipcOn()` now return a `Disposable`, allowing manual early unregistration if needed (e.g., `const d = this.ipcHandle(...); d.dispose()`). For most services, automatic cleanup on stop is sufficient.
**Migration caveat**: Services using `ipcMain.removeAllListeners(channel)` (e.g., CacheService) need careful review — `this.ipcOn()` tracks specific listeners and uses `removeListener()`, not `removeAllListeners()`. If other code also listens on the same channel, this is the correct behavior; if the intent was to remove all listeners, verify the migration won't leave orphans.
### Step 8b: Migrate recurring timers to `registerInterval`
Replace lifecycle-scoped `setInterval()` with `this.registerInterval()` — handles `unref()`, exception isolation, and cleanup via the disposable channel.
```typescript
// OLD
private gcInterval: NodeJS.Timeout | null = null
protected async onStop() {
if (this.gcInterval) { clearInterval(this.gcInterval); this.gcInterval = null }
}
private startGc() {
if (this.gcInterval) return
this.gcInterval = setInterval(() => this.gc(), 60_000)
this.gcInterval.unref()
}
// NEW
private gcInterval: Disposable | null = null
protected async onStop() {
this.gcInterval = null // auto-disposed; null'd so restart re-arms
}
private startGc() {
if (this.gcInterval) return
this.gcInterval = this.registerInterval(() => this.gc(), 60_000)
}
```
If the field is never read (e.g., fire-and-forget from `onInit`), drop it entirely.
**Do not migrate**: one-shot `setTimeout` (debounces), connection-scoped heartbeats (Discord/Slack/QQ adapters), activation-scoped timers in `Activatable` services.
## Before/After Summary
| Aspect | Before | After |
| -------------- | ------------------------------------------- | -------------------------------------------- |
| Singleton | `private static instance` + `getInstance()` | `@Injectable('Name')` — container manages it |
| Init | Manual `init()` called from `index.ts` | `onInit()` — called automatically |
| Cleanup | Manual cleanup in `will-quit` / `before-quit` handler | `onStop()` / `onDestroy()` — automatic |
| Dependencies | `import { otherService } from '...'` | `@DependsOn([...])` + `application.get()` |
| Access | `import { myService } from '...'` | `application.get('MyService')` |
| Ordering | Manual call order in `index.ts` | `@ServicePhase` + `@DependsOn` + `@Priority` |
| Error handling | try/catch in `index.ts` | `@ErrorHandling('fail-fast' \| 'graceful')` |
| IPC handlers | Manual `ipcMain.handle()` + `removeHandler()` | `this.ipcHandle()` — auto-cleanup on stop |
| Recurring timers | Manual `setInterval()` + `clearInterval()` + `unref()` | `this.registerInterval()` — auto-cleanup, auto-unref, exception-isolated |
### Step 9: Migrate ad-hoc event communication to Emitter/Event
If the old service used `app.emit()` / `app.on()` or custom EventEmitter patterns for inter-service communication, replace them with typed `Emitter<T>` / `Event<T>`:
```typescript
// OLD — ad-hoc event on Electron's app object
// Producer:
app.emit('main-window-created', this.mainWindow)
// Consumer:
;(app as NodeJS.EventEmitter).on('main-window-created', (event, window) => { ... })
// Manual cleanup in onStop():
;(app as NodeJS.EventEmitter).off('main-window-created', this.handler)
// NEW — typed Emitter/Event
// Producer:
private readonly _onMainWindowCreated = new Emitter<BrowserWindow>()
public readonly onMainWindowCreated: Event<BrowserWindow> = this._onMainWindowCreated.event
// Fire:
this._onMainWindowCreated.fire(this.mainWindow)
// Consumer:
this.registerDisposable(
windowService.onMainWindowCreated((window) => { ... })
)
// No manual cleanup needed — registerDisposable handles it
```
See [Service Events](./lifecycle-usage.md#service-events-emitter--event) for full patterns.
## Common Pitfalls
1. **Constructor side effects** — Old services often do work in the constructor (event listeners, timers). Move all side effects to `onInit()`. The constructor should only assign default values.
2. **Top-level `application.get()` calls**`application.get()` only works after the service is registered and bootstrapping has started. Never call it at module scope:
```typescript
// ✗ BAD — runs at import time, before bootstrap
const preferenceService = application.get('PreferenceService')
@Injectable('MyService')
export class MyService extends BaseService {
// ✓ GOOD — runs during bootstrap, dependencies are ready
protected async onInit() {
const preferenceService = application.get('PreferenceService')
}
}
```
3. **Circular dependencies** — If ServiceA depends on ServiceB and vice versa, refactor so that the non-critical direction uses `onAllReady()` instead of `@DependsOn`:
```typescript
@Injectable('ServiceA')
@DependsOn(['ServiceB']) // ← hard dependency
export class ServiceA extends BaseService { ... }
@Injectable('ServiceB')
// No @DependsOn on ServiceA — would be circular
export class ServiceB extends BaseService {
protected onAllReady() {
// Safe to access ServiceA here — all services are ready
const a = application.get('ServiceA')
}
}
```
4. **Forgetting to remove old exports** — After migration, grep for the old export name (e.g., `windowService`) across the codebase. Any remaining imports will break at runtime.