mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 14:01:30 +08:00
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.
337 lines
12 KiB
Markdown
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.
|