How the karukan-imserver JSON-RPC 2.0 Protocol Facilitates macOS Frontend Communication

The karukan-imserver JSON-RPC 2.0 protocol enables macOS frontend communication through a lightweight, newline-delimited JSON-RPC 2.0 channel over stdin and stdout, allowing the Swift-based Input Method Kit (IMK) extension to synchronously invoke Rust engine methods while maintaining type safety across language boundaries.

The karukan input method engine separates its high-performance Rust core from the macOS frontend through a well-defined IPC mechanism. According to the togatoga/karukan source code, the karukan-imserver binary exposes a JSON-RPC 2.0 interface that the Swift-based Input Method Extension uses to process keystrokes, manage candidates, and handle composition states without blocking the main thread.

Process Spawning and Transport Layer

The macOS frontend initiates communication by spawning karukan-imserver as a child process via EngineProcess. The transport layer uses newline-delimited JSON-RPC 2.0 messages exchanged over the child process's stdin and stdout streams.

The Swift side writes JSON-RPC request objects to the server's stdin and reads response lines from stdout. Each line represents a complete JSON-RPC message, while any text written to stderr is treated as log output only. This unidirectional flow ensures that the Rust engine can operate independently while the Swift frontend manages the Input Method Kit lifecycle.

Source: karukan-macos/Sources/KarukanIME/EngineClient.swift lines 3-8, 31-33.

JSON-RPC 2.0 Message Envelope

The protocol implements the standard JSON-RPC 2.0 envelope structure containing jsonrpc, id, method, params, result, and error fields. On the Rust side, these structures are defined in karukan-im/src/server/protocol.rs as Request, Response, and RpcError structs.

According to the source code at lines 38-55, the envelope guarantees that every request receives a matching response with the same identifier, enabling asynchronous dispatch while preserving call ordering. The jsonrpc field is always set to "2.0" to indicate protocol compliance.

Method Catalogue and API Surface

The server exposes a minimal, purpose-built API surface designed specifically for IME operations. The following methods define the contract between the macOS frontend and the Rust engine:

Method Parameters Return Type
init {} InitResult
process_key ProcessKeyParams KeyResult
select_candidate SelectCandidateParams KeyResult
commit {} KeyResult
reset {} {}
set_surrounding_text SurroundingTextParams {}
save_learning {} {}
status {} StatusResult

The Rust definitions for these parameter and result types reside in the same protocol file. For example, ProcessKeyParams (lines 101-110) captures key symbols and modifier states, while InitResult (lines 126-131) returns the protocol version and engine configuration.

Swift Protocol Mirror

The macOS frontend maintains type safety through a Swift mirror of the Rust protocol defined in EngineProtocol.swift. All structs conform to Decodable (and Encodable where needed) and use automatic snake_case-to-camelCase conversion via a custom JSONDecoder.

The makeProtocolDecoder function at line 75 configures the decoder to convert snake_case field names from the wire format into Swift's camelCase convention. This ensures that the Swift structs match the literal JSON field names sent by the Rust server while maintaining Swift naming conventions.

Source: karukan-macos/Sources/KarukanIME/EngineProtocol.swift lines 1-30.

Request Lifecycle and Synchronization

The EngineClient class manages the complete request lifecycle through two primary mechanisms: sending and response handling.

Sending requests: The sendRequest method constructs a JSON object containing "jsonrpc":"2.0", a monotonically increasing "id", the "method" name, and a "params" dictionary. The object is serialized, appended with a newline (0x0A), and written to the server's stdin.

Reading responses: The startReaderLoop reads arbitrary chunks from stdout, splits on the newline byte (0x0A), and passes each line to handleResponse. The response JSON is parsed, the "id" field locates the pending completion closure, and the "result" object is re-encoded and returned to the caller.

Source: EngineClient.swift sections 31-34 (sending) and 66-92 (reading).

Synchronous vs. Asynchronous Operations

The protocol supports both blocking and non-blocking call patterns:

  • Synchronous key processing: The processKeySync method blocks using a DispatchSemaphore because the Input Method Kit's handle callback must immediately return whether the key was consumed. This method sends the request, waits for the response, and returns the decoded KeyResult.

  • Fire-and-forget actions: Operations like save_learning or set_surrounding_text use sendRequest with no-op completions, allowing the engine to process these updates asynchronously without blocking the UI.

Version Safety and Cross-Language Type Safety

The protocol includes a versioning guard to prevent incompatible frontend-engine combinations. During initialization, the server returns a protocol_version field in the InitResult. The Swift client checks this against the expected PROTOCOL_VERSION constant (currently 1) defined in Rust.

If the versions mismatch, the client can abort the connection, preventing subtle serialization errors when the wire format changes. This mechanism ensures that updates to either the Rust engine or Swift frontend maintain backward compatibility or fail explicitly.

The project maintains a single source of truth by keeping the canonical type definitions in protocol.rs and manually mirroring them in EngineProtocol.swift. Continuous integration tests in EngineProtocolTests.swift verify that the Swift decoder can correctly parse real InitResult JSON payloads, catching drift between the language implementations.

Code Example

The following Swift code demonstrates typical interactions with the karukan-imserver JSON-RPC 2.0 protocol:

// Initialize the engine (sent automatically on server restart)
client.initAsync()

// Send a key event synchronously (required for IMK handle:)
let keyEvent = EngineKeyEvent(
    keysym: 0x0061, 
    modifiers: .init(shift: false, control: false, alt: false, super: false)
)
if let result = client.processKeySync(keyEvent) {
    // result.actions contains UI updates (preedit, candidates, etc.)
    applyEngineActions(result.actions)
}

// Commit the current composition
if let commitResult = client.commitSync() {
    applyEngineActions(commitResult.actions)
}

// Update surrounding text (inform the engine about external edits)
client.setSurroundingTextAsync(text: fullText, cursorPos: caret)

Summary

  • The karukan-imserver JSON-RPC 2.0 protocol uses newline-delimited messages over stdin/stdout to bridge the Swift macOS frontend and Rust IME engine.
  • Message envelopes follow the JSON-RPC 2.0 standard with monotonic IDs for request-response correlation.
  • The API surface includes seven primary methods: init, process_key, select_candidate, commit, reset, set_surrounding_text, and save_learning.
  • Type safety is maintained through mirrored Rust and Swift structs with automatic case conversion.
  • Synchronization supports both blocking calls (for key processing) and asynchronous operations (for state updates).
  • Version guards prevent incompatible frontend-engine combinations by validating protocol_version during initialization.

Frequently Asked Questions

How does the karukan-imserver JSON-RPC 2.0 protocol handle concurrent requests?

The protocol handles concurrency through a monotonic ID system where each request receives a unique identifier. The EngineClient maintains a dictionary of pending completion closures keyed by these IDs. When startReaderLoop parses a response, it matches the response ID to the pending request and dispatches the result on the appropriate queue. This allows multiple asynchronous operations to be in flight while ensuring that synchronous calls like processKeySync can block using a DispatchSemaphore without deadlocking the response reader.

What transport mechanism does the karukan-imserver use for JSON-RPC communication?

The transport mechanism uses standard input/output streams rather than network sockets or pipes. The Swift frontend spawns karukan-imserver as a child process and writes JSON-RPC messages followed by newline characters (0x0A) to the child's stdin. Responses are read line-by-line from stdout, with each line representing a complete JSON object. This approach eliminates network overhead and firewall concerns while providing a reliable, byte-stream-based communication channel between the macOS Input Method Kit extension and the Rust engine.

Why does the protocol require synchronous key processing for certain operations?

Synchronous processing is required because the Input Method Kit (IMK) framework mandates immediate responses from the handle callback to determine whether a keystroke was consumed by the IME. When a user types, macOS calls the input method's handle method, which must return a boolean indicating consumption before the next event processes. The processKeySync method uses a DispatchSemaphore to block the calling thread until the Rust engine returns a KeyResult, ensuring the IME can correctly intercept or pass through keystrokes without introducing input latency or event reordering.

How does the protocol prevent version mismatches between the Swift frontend and Rust backend?

The protocol implements a version handshake during the init method call. The Rust server returns a protocol_version field (currently hardcoded to 1 in the PROTOCOL_VERSION constant) within the InitResult struct. The Swift client compares this value against its own expected version constant and can terminate the connection if they differ. This verification occurs before any other API calls, ensuring that structural changes to JSON payloads or method signatures do not cause silent deserialization failures or undefined behavior in the IME.

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 →