How to Connect iOS Client to Desktop Core via TunnelTransport

The iOS client connects to the desktop core by instantiating a TunnelTransport with a channel ID, authentication token, and the core's Base-64 public key, then registering it with the TransportManager to route end-to-end encrypted RPC calls through a Socket.IO-based relay.

The OpenHuman project uses a transport abstraction called TunnelTransport to bridge the React-based iOS client with the Rust desktop core over the internet. This guide explains how to establish that connection using the actual implementation found in the tinyhumansai/openhuman repository.

Understanding the TunnelTransport Architecture

The tunnel system relies on a CoreTransport abstraction that works identically on both desktop and mobile clients. On the iOS side, the TunnelTransport class (TypeScript) handles the Socket.IO connection and XChaCha20-Poly1305 encryption, while the desktop core runs a small Rust client in src/openhuman/devices/tunnel_client.rs to decrypt and process packets.

The connection flow requires three components:

  • A tunnel relay URL (public Socket.IO endpoint)
  • A channel ID and short-lived auth token generated by the core
  • The core's public key for session key negotiation

Step 1: Request a Tunnel Channel from the Desktop Core

Before the iOS client can connect, it must ask the running desktop core to create a tunnel channel. This is done via the core.tunnel_create JSON-RPC method, which is defined in the Rust RPC schemas (typically located in src/openhuman/*/schemas.rs).

From your iOS client code, call the RPC endpoint:

import { coreRpcClient } from '@/services/api/coreRpcClient';

async function requestTunnelCredentials() {
  const { channelId, token, corePublicKey } = await coreRpcClient.call(
    'core.tunnel_create',
    {} // no arguments required
  );
  return { channelId, token, corePublicKey };
}

The core returns a unique channelId for this session, a short-lived token for authentication, and the corePublicKey (Base-64 encoded) needed for encryption handshake.

Step 2: Instantiate TunnelTransport on the iOS Client

With the credentials in hand, create a TunnelTransport instance and register it with the TransportManager. The manager (located in app/src/services/transport/TransportManager.ts) handles transport selection and lifecycle across the OpenHuman app.

import { TunnelTransport } from '@/services/transport/TunnelTransport';
import { TransportManager } from '@/services/transport/TransportManager';

async function establishTunnelConnection() {
  // 1. Obtain credentials from the core
  const { channelId, token, corePublicKey } = await requestTunnelCredentials();

  // 2. Configure the tunnel backend URL (must match the desktop core's relay)
  const tunnelBackendUrl = 'https://backend.tunnel.openhuman.dev';

  // 3. Create the transport instance
  const tunnel = new TunnelTransport(
    tunnelBackendUrl,
    channelId,
    corePublicKey, // Base-64 encoded
    token
  );

  // 4. Register with the manager and start the connection
  const manager = new TransportManager();
  manager.setTransport(tunnel);
  await manager.start(); // Opens Socket.IO connection
  
  console.log('TunnelTransport connected and ready');
}

The TunnelTransport constructor (implemented in app/src/services/transport/TunnelTransport.ts) immediately prepares the XChaCha20-Poly1305 handshake but defers the actual Socket.IO connection until manager.start() is invoked.

Step 3: How the Encrypted Tunnel Works Under the Hood

Once start() is called, the following sequence occurs:

  1. Socket.IO Negotiation: The iOS client connects to the tunnel relay via Socket.IO, presenting the channelId to join the correct room.
  2. Key Exchange: The client and core perform a handshake using the core's long-term public key and the short-lived auth token to derive a per-session XChaCha20-Poly1305 key (providing forward secrecy).
  3. Encrypted RPC: All JSON-RPC payloads are encrypted by TunnelTransport.emit() before transmission. The desktop core's tunnel_client.rs decrypts packets and forwards them to the internal RPC router.
  4. Bidirectional Flow: Responses from the core are encrypted with the same session key and decrypted by the iOS client before being returned to the calling service.

Debug logs for this flow are prefixed with [transport:tunnel] in both the TypeScript client and Rust core when verbose logging is enabled.

Complete iOS Integration Example

Place this initialization logic in your root component startup sequence (e.g., inside a useEffect hook) to ensure the transport is ready before any RPC calls are made:

import { useEffect } from 'react';
import { coreRpcClient } from '@/services/api/coreRpcClient';
import { TransportManager } from '@/services/transport/TransportManager';
import { TunnelTransport } from '@/services/transport/TunnelTransport';

export function useCoreConnection() {
  useEffect(() => {
    async function init() {
      try {
        // Request tunnel from desktop core
        const { channelId, token, corePublicKey } = await coreRpcClient.call(
          'core.tunnel_create',
          {}
        );

        // Initialize tunnel transport
        const tunnel = new TunnelTransport(
          process.env.VITE_TUNNEL_RELAY_URL || 'https://backend.tunnel.openhuman.dev',
          channelId,
          corePublicKey,
          token
        );

        // Set up manager and connect
        const manager = new TransportManager();
        manager.setTransport(tunnel);
        await manager.start();

        console.log('✅ iOS client connected to desktop core via TunnelTransport');
      } catch (error) {
        console.error('Failed to establish tunnel:', error);
      }
    }

    init();
  }, []);
}

Configuration and Troubleshooting Tips

Environment Variables: Ensure the iOS client uses the same tunnel relay URL that the desktop core expects. The core reads OPENHUMAN_TUNNEL_RELAY_URL from its environment, while the iOS client typically uses VITE_TUNNEL_RELAY_URL.

Token Expiry: Tunnel tokens are valid for approximately 5 minutes. If you encounter "unauthorized" or "channel not found" errors, request a fresh channel by calling core.tunnel_create again.

Firewall and NAT Traversal: The tunnel functions even when the iOS device is behind restrictive firewalls or NAT because the connection originates as an outbound WebSocket to the public relay.

Debugging: Enable verbose logging by setting OPENHUMAN_LOG=debug in the desktop core environment and VITE_LOG_LEVEL=debug in the iOS build configuration. Search logs for the [transport:tunnel] prefix to trace the handshake and packet flow.

Summary

  • The desktop core exposes core.tunnel_create to generate temporary channel credentials and a public key for encryption.
  • The iOS client instantiates TunnelTransport (from app/src/services/transport/TunnelTransport.ts) with the relay URL, channel ID, core public key, and auth token.
  • Register the transport with TransportManager (from app/src/services/transport/TransportManager.ts) and call start() to initiate the Socket.IO connection.
  • All traffic is encrypted with XChaCha20-Poly1305, decrypted by the Rust tunnel_client.rs, and processed as standard RPC by the core.

Frequently Asked Questions

What is TunnelTransport in the OpenHuman architecture?

TunnelTransport is a TypeScript class that implements the CoreTransport interface to provide end-to-end encrypted communication between the iOS client and desktop core. It wraps Socket.IO and handles XChaCha20-Poly1305 encryption, making it suitable for internet-based connections where direct TCP might be blocked by firewalls.

How long does a tunnel channel remain valid?

Tunnel channels and their associated auth tokens expire after approximately 5 minutes. According to the implementation in src/openhuman/devices/tunnel_client.rs, these tokens are single-use and short-lived to prevent replay attacks. If your connection attempt fails with an authentication error, simply request a new channel via core.tunnel_create.

Can the tunnel work if the iOS device is on a cellular network?

Yes. Because the TunnelTransport initiates an outbound WebSocket connection to the public relay server, it works across cellular networks, corporate firewalls, and NAT environments. The desktop core maintains a corresponding connection to the same relay, allowing bidirectional data flow without requiring inbound ports to be open on either device.

Where is the encryption handled in the source code?

Encryption is handled on both sides: the iOS client encrypts payloads in app/src/services/transport/TunnelTransport.ts before emitting them over Socket.IO, and the desktop core decrypts them in src/openhuman/devices/tunnel_client.rs before passing the plaintext RPC to the core's internal router. The symmetric key is derived during the initial handshake using the core's long-term public key and the ephemeral session token.

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 →