Files
CherryHQ-cherry-studio/docs/references/ipc/ipc-usage.md
fullex 840f812690 refactor(barrels): move impl out of index barrels per naming §6.4
Clear all `barrel/index-no-impl` violations in src/main (14 barrels) by
turning each index.ts into a pure re-export or dissolving it:

- Aggregation buckets → drop the barrel, move the assembled object/array/fn
  to a named file, deep-import at the sole consumer: data/api/handlers →
  apiHandlers.ts, ipc/handlers → ipcHandlers.ts, migrators → migratorRegistry.ts,
  params/features → internalFeatures.ts, browser/tools → registry.ts,
  builtin → registerBuiltinTools.ts, db/seeding → seederRegistry.ts.
- Single-impl dirs flattened: provider/extensions.ts, provider/cherryai.ts.
- Barrel kept, impl extracted to a named file: i18n/resolver.ts,
  ai/types/providerConfig.ts.
- Route subdir flattened to match its flat siblings: routes/knowledge.ts +
  routes/knowledgeSchemas.ts.
- runtime: replace the import-time `claudeCode/register` side effect with an
  explicit registerRuntimeDrivers() invoked from AgentSessionRuntimeService.onInit,
  so runtime/index.ts stays a pure re-export.
- loop: dissolve the non-enforced barrel into loop/types.ts + loop/hookRunner.ts.

Also route DataApiService's apiHandlers import through the data/api barrel,
and expose ClaudeCodeRuntimeDriver via the claudeCode barrel, clearing the two
deep-import warnings this batch touched. Docs and comments updated to the new paths.
2026-07-05 09:48:02 -07:00

7.2 KiB
Raw Permalink Blame History

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)

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/ipcSchemas.ts):

export const ipcRequestSchemas = {
  ...windowRequestSchemas
} satisfies Record<string, RouteDef>

2. Implement the handler (src/main/ipc/handlers/<domain>.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/ipcHandlers.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

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 codeIpcApiService 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 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 — errors/ has no aggregating index; each domain map is imported directly):

// 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
// main handler (src/main/ipc/handlers/file.ts)
import { IpcError } from '@shared/ipc/errors/IpcError'
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)
}
// renderer — branch on the rebuilt IpcError's `code` using the same constant
import { IpcError } from '@shared/ipc/errors/IpcError'
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)

export type WindowEventSchemas = {
  'window.maximized_changed': { maximized: boolean }
}

Register it in the composition (schemas/ipcSchemas.ts):

export type IpcEventSchemas = WindowEventSchemas & AppEventSchemas

2. Emit from a main service

// 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

import { useIpcOn } from '@renderer/ipc/useIpcOn'

useIpcOn('window.maximized_changed', ({ maximized }) => setMax(maximized)) // cleanup is automatic

Outside React, use the imperative form:

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.