mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 13:47:59 +08:00
Move src/shared/ipc/errors.ts to src/shared/ipc/errors/index.ts so each migrated domain can host its own error-code map as a sibling errors/<domain>.ts — value-importable by both processes and zod-free. The @shared/ipc/errors barrel path is unchanged, so every importer and test resolves identically (typecheck + IPC tests green).
Document the IpcErrorCode usage convention that previously lived only in code comments: the framework-code single source of truth, the open (string & {}) tail for domain codes, where domain codes belong (errors/, not schemas/, since the renderer may only import type from schemas), and why they are imported directly rather than aggregated through a barrel.
169 lines
7.2 KiB
Markdown
169 lines
7.2 KiB
Markdown
# IpcApi Usage
|
||
|
||
Two recurring tasks: adding a request route (R→M call) and adding an event (M→R push). A new request changes **2 places** (schema + handler); a new event changes **1 contract** plus its emit and subscribe sites. Preload and the channel enum never change.
|
||
|
||
## Add a Request Route
|
||
|
||
### 1. Declare the schema (`src/shared/ipc/schemas/<domain>.ts`)
|
||
|
||
```ts
|
||
import { z } from 'zod'
|
||
import { defineRoute } from '../define'
|
||
|
||
export const windowRequestSchemas = {
|
||
// route: dot snake_case; payload fields stay camelCase
|
||
'window.set_minimum_size': defineRoute({
|
||
input: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }),
|
||
output: z.void()
|
||
})
|
||
}
|
||
```
|
||
|
||
Register it in the composition (`src/shared/ipc/schemas/index.ts`):
|
||
|
||
```ts
|
||
export const ipcRequestSchemas = {
|
||
...windowRequestSchemas
|
||
} satisfies Record<string, RouteDef>
|
||
```
|
||
|
||
### 2. Implement the handler (`src/main/ipc/handlers/<domain>.ts`)
|
||
|
||
```ts
|
||
import type { IpcHandlersFor } from '@shared/ipc/types'
|
||
import type { windowRequestSchemas } from '@shared/ipc/schemas/window'
|
||
|
||
export const windowHandlers: IpcHandlersFor<typeof windowRequestSchemas> = {
|
||
// input is the parsed type; ctx.senderId is the caller WindowId (omit ctx if unused)
|
||
'window.set_minimum_size': async ({ width, height }, { senderId }) => {
|
||
if (senderId != null) application.get('WindowManager').setMinimumSize(senderId, width, height)
|
||
}
|
||
}
|
||
```
|
||
|
||
Register it (`src/main/ipc/handlers/index.ts`):
|
||
|
||
```ts
|
||
export const ipcHandlers: IpcHandlersFor<IpcRequestSchemas> = {
|
||
...windowHandlers
|
||
}
|
||
```
|
||
|
||
Miss a declared route → compile error. Add a handler for an undeclared route → compile error.
|
||
|
||
### 3. Call it from the renderer
|
||
|
||
```ts
|
||
import { ipcApi } from '@renderer/ipc'
|
||
|
||
await ipcApi.request('window.set_minimum_size', { width: 800, height: 600 })
|
||
const info = await ipcApi.request('app.get_info') // void input → no second argument
|
||
```
|
||
|
||
`route` is completed/checked against `IpcRoute`; input/output types follow from it. On failure the call rejects with an `IpcError` (its `code` lets you branch).
|
||
|
||
### 4. Surface a typed error (optional)
|
||
|
||
To signal a failure the renderer must branch on, throw an `IpcError` with a **domain code** — `IpcApiService` serializes it into `{ ok: false, error }` and the renderer facade rebuilds the `IpcError` and rejects. Do **not** throw the framework codes (`VALIDATION_FAILED` / `ROUTE_NOT_FOUND` / `FORBIDDEN_SENDER` / `INTERNAL`) by hand — the router owns those, and any uncaught non-`IpcError` throw is normalized to `INTERNAL` for you. See the [error model](./ipc-overview.md#error-codes--ipcerrorcode) for the framework-vs-domain-code rule and why codes live under `errors/`, not `schemas/`.
|
||
|
||
Put the domain's codes in `@shared/ipc/errors/<domain>.ts` as an `as const` map, and import it **directly** on both sides (no barrel — there is no aggregated `errors/index.ts` export of domain codes):
|
||
|
||
```ts
|
||
// src/shared/ipc/errors/file.ts — the domain's code map (zod-free, value-importable by both processes)
|
||
export const fileErrorCodes = { FILE_NOT_FOUND: 'FILE_NOT_FOUND' } as const
|
||
```
|
||
|
||
```ts
|
||
// main handler (src/main/ipc/handlers/file.ts)
|
||
import { IpcError } from '@shared/ipc/errors'
|
||
import { fileErrorCodes } from '@shared/ipc/errors/file'
|
||
|
||
'file.read_doc': async ({ path }) => {
|
||
if (!(await exists(path))) {
|
||
// reference the constant, not a literal; machine-readable detail rides in `data`
|
||
throw new IpcError(fileErrorCodes.FILE_NOT_FOUND, `No file at ${path}`, { path })
|
||
}
|
||
return read(path)
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// renderer — branch on the rebuilt IpcError's `code` using the same constant
|
||
import { IpcError } from '@shared/ipc/errors'
|
||
import { fileErrorCodes } from '@shared/ipc/errors/file'
|
||
|
||
try {
|
||
await ipcApi.request('file.read_doc', { path })
|
||
} catch (e) {
|
||
if (e instanceof IpcError && e.code === fileErrorCodes.FILE_NOT_FOUND) showMissing((e.data as { path: string }).path)
|
||
else throw e
|
||
}
|
||
```
|
||
|
||
## Add an Event
|
||
|
||
### 1. Declare the contract (Event block of `schemas/<domain>.ts`)
|
||
|
||
```ts
|
||
export type WindowEventSchemas = {
|
||
'window.maximized_changed': { maximized: boolean }
|
||
}
|
||
```
|
||
|
||
Register it in the composition (`schemas/index.ts`):
|
||
|
||
```ts
|
||
export type IpcEventSchemas = WindowEventSchemas & AppEventSchemas
|
||
```
|
||
|
||
### 2. Emit from a main service
|
||
|
||
```ts
|
||
// to all windows
|
||
application.get('IpcApiService').broadcast('window.maximized_changed', { maximized: true })
|
||
// to one window (e.g. the caller, by its WindowId)
|
||
application.get('IpcApiService').send(windowId, 'window.maximized_changed', { maximized: true })
|
||
```
|
||
|
||
### 3. Subscribe in the renderer
|
||
|
||
```ts
|
||
import { useIpcOn } from '@renderer/ipc/useIpcOn'
|
||
|
||
useIpcOn('window.maximized_changed', ({ maximized }) => setMax(maximized)) // cleanup is automatic
|
||
```
|
||
|
||
Outside React, use the imperative form:
|
||
|
||
```ts
|
||
const unsubscribe = ipcApi.on('window.maximized_changed', (p) => { /* ... */ })
|
||
```
|
||
|
||
## Handler: Pure Function vs Service Delegate
|
||
|
||
| Capability | Where the handler lives |
|
||
|---|---|
|
||
| Stateless (app info, font list) | Pure function directly in `handlers/` — no service needed |
|
||
| Stateful (MCP / Knowledge / Window) | Handler in `handlers/`, delegating via `application.get('XxxService').method()`; business logic and resource lifecycle stay in the service |
|
||
|
||
The `handlers/` directory is the single audited list of every main capability the renderer can reach.
|
||
|
||
## Testing
|
||
|
||
Test the **handler**, not the schema. A per-domain schema is a thin structural contract — a TS type's runtime mirror — so asserting that `z.boolean()` rejects a string, or that `z.infer` yields `boolean`, only re-tests zod. The contract is already locked three ways:
|
||
|
||
1. compile-time `IpcHandlersFor<typeof schemas>` — every route needs a handler, no extras;
|
||
2. `z.infer` drives the handler signature and the renderer call types — a mismatch is a compile error;
|
||
3. the single framework type test (`src/shared/ipc/__tests__/schema.types.test.ts`) exercises the reusable `IpcHandlersFor` generic once.
|
||
|
||
So unit-test the handler (`src/main/ipc/handlers/__tests__/<domain>.test.ts`) for real behavior — senderId routing, null-window fallback, service delegation — and do **not** add a per-domain `schemas/__tests__`. Business validation belongs in the handler/service, not the schema, so a schema with custom logic worth testing effectively never arises; if a genuine custom `.refine` predicate ever appears, test that predicate as a plain function rather than through the schema.
|
||
|
||
## High-Frequency / Topic Streams
|
||
|
||
Token streams and file-tree mutations do **not** go through `broadcast`. The owning service keeps a listener registry (preserving its batching) and directs `send(windowId, …)` per topic to attached windows — avoiding the O(windows × frequency) fan-out of broadcasting a hot event. See the migration guide (class B).
|
||
|
||
The two directions diverge under load:
|
||
|
||
- **M→R high-frequency** stays in IpcApi — its transport is already one-way `webContents.send`, so frequency costs no extra round-trip; just use directed `send` + batching (above).
|
||
- **R→M high-frequency** (per-frame, e.g. tab-drag window moves) gets no such luck — R→M is `invoke`/`handle`, so the rare per-frame channel may leave IpcApi via the escape hatch. See the [migration guide](./ipc-migration-guide.md#escape-hatch--when-a-channel-may-stay-out).
|