How the Karukan Swift/InputMethodKit Frontend Communicates with the Engine Process

The Karukan macOS frontend communicates with its Rust engine process via JSON-RPC messages piped over standard input and standard output streams.

The Karukan input method editor (IME) for macOS separates its user-facing Swift/InputMethodKit frontend from the heavy-duty conversion logic written in Rust. This architecture requires a robust inter-process communication mechanism that keeps the UI responsive while handling complex kana-kanji conversions. The solution implemented in the togatoga/karukan repository uses a lightweight JSON-RPC protocol tunneled through stdin/stdout pipes, eliminating the need for custom sockets or complex IPC frameworks.

Process Spawning and Pipe Management

The communication channel begins with the EngineProcess class in karukan-macos/Sources/KarukanIME/EngineProcess.swift. This class encapsulates the lifecycle management of the karukan-imserver binary, which is the Rust-based conversion engine.

When the IME loads, EngineProcess initializes a Process instance pointing to the bundled server binary at Bundle.main.bundlePath + "/Contents/MacOS/karukan-imserver". It attaches two Pipe objects to the child process: stdinPipe for sending requests and stdoutPipe for receiving responses. This configuration creates a straightforward byte stream interface where the Swift frontend writes JSON to the engine's standard input and reads JSON from its standard output.

The class implements intelligent restart logic through a termination handler that monitors the child process exit status. If shouldRestart is set to true, the system schedules an exponential backoff restart sequence. Additionally, EngineProcess exposes an onRestart closure that the JSON-RPC client uses to reattach its reader loop after the engine process respawns, ensuring seamless recovery from crashes without user intervention.

JSON-RPC Client Implementation

With the pipes established, EngineClient in karukan-macos/Sources/KarukanIME/EngineClient.swift implements the actual JSON-RPC protocol layer. This class owns the file handles from EngineProcess and manages all message serialization and deserialization.

The client runs a background read loop on a dedicated dispatch queue that continuously monitors stdoutPipe.fileHandleForReading for incoming JSON messages. For synchronous operations such as process_key, the client encodes a JSON-RPC request containing an id, method, and params, writes it to stdinPipe.fileHandleForWriting, then blocks on a DispatchSemaphore until the matching response arrives. This synchronous blocking is acceptable because the read loop operates on a background queue, keeping the main thread responsive to macOS IMK events.

For fire-and-forget notifications like log_event or commit_candidate, the client sends the JSON payload without awaiting a response, allowing the frontend to notify the engine of state changes without blocking the input loop.

// Example: Send a key event synchronously
func handleKey(_ event: NSEvent, client: EngineClient) {
    let request = ProcessKeyRequest(
        keyCode: event.keyCode,
        modifiers: event.modifierFlags.rawValue
    )
    do {
        let response: EngineResult = try client.request(
            method: "process_key",
            params: request,
            responseType: EngineResult.self
        )
        // Update pre-edit text or candidate list from response
    } catch {
        NSLog("KarukanIME: JSON-RPC error – \(error)")
    }
}

// Example: Fire-and-forget notification
client.notify(method: "commit_candidate", params: CommitInfo(candidateId: selectedId))

Message Protocol and Data Structures

To ensure type safety across the Swift/Rust boundary, EngineProtocol.swift defines the shared message schema. This file contains Swift structs that mirror the Rust-side definitions for Request, Response, and Notification types, along with a shared protocol_version constant.

By maintaining identical JSON shapes in both languages, the system avoids serialization mismatches. The protocol defines specific method strings such as process_key for conversion requests and commit_candidate for selection events, with corresponding parameter structs that encode key codes, modifier flags, and candidate identifiers.

IMK Controller Integration

The final layer connects these transport mechanisms to macOS's InputMethodKit framework. KarukanInputController.swift holds an EngineClient instance and acts as the bridge between IMK events and engine calls.

When the user types a key, the controller receives the NSEvent and invokes engineClient.processKey(event:). This method constructs a ProcessKeyRequest, sends it via the JSON-RPC channel, and receives an EngineResult containing pre-edit text, candidate lists, or commit strings. The controller then forwards these results to the appropriate IMK APIs to update the inline input buffer or display candidate windows.

This architecture cleanly separates concerns: the Swift layer handles only macOS UI events and IMK protocol compliance, while the Rust engine manages dictionary lookup, kana-kanji conversion, and learning cache updates.

Summary

  • EngineProcess.swift spawns the karukan-imserver binary and manages stdin/stdout pipes with automatic restart capability.
  • EngineClient.swift implements JSON-RPC over these pipes, supporting both synchronous requests (using DispatchSemaphore) and asynchronous notifications.
  • EngineProtocol.swift provides shared message definitions ensuring Swift/Rust compatibility.
  • KarukanInputController.swift bridges IMK events to the RPC client, keeping the UI responsive while the engine handles heavy conversion tasks.
  • All communication uses plain JSON over standard streams, requiring no custom sockets or complex IPC mechanisms.

Frequently Asked Questions

Why does Karukan use JSON-RPC over pipes instead of XPC or network sockets?

The pipe-based approach minimizes dependencies and avoids sandboxing complications common with XPC services. By using stdin/stdout, Karukan leverages standard POSIX streams that work reliably across process boundaries without requiring additional entitlements or socket management code. This design also simplifies debugging, as developers can intercept the JSON traffic by launching the engine manually in a terminal.

How does the Swift frontend recover if the Rust engine process crashes?

The EngineProcess class monitors the child process through a termination handler. When the engine exits unexpectedly, the handler checks the shouldRestart flag and initiates an exponential backoff sequence to respawn the process. The onRestart closure notifies the EngineClient to reattach its reader loop to the new process's stdout pipe, restoring communication without requiring the user to restart the IME.

What happens if a conversion request times out or the engine hangs?

Since the EngineClient uses a DispatchSemaphore to wait for synchronous responses, a hung engine would block the requesting thread indefinitely. In practice, the production implementation should include timeout logic on the semaphore wait or leverage the process monitoring in EngineProcess to detect unresponsive states and trigger a restart. The modular architecture allows the frontend to degrade gracefully by clearing the pre-edit buffer if the engine becomes unresponsive.

Is the communication between the frontend and engine bidirectional?

While the primary flow is request-response from Swift to Rust, the protocol supports server-initiated notifications sent from engine to frontend. However, the current architecture primarily uses the stdout pipe for responses to client-initiated requests. True bidirectional streaming would require additional message routing logic in EngineClient.swift to handle async server pushes separately from response correlation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →