MÓDULO 4.3

📱 Cliente iOS (experimental)

iPhone não roda core Rust on-device. Conecta ao desktop por três transportes diferentes, com criptografia E2E, PTT nativo e pareamento por QR. Status: não-shipping, em construção.

6
Tópicos
45
Minutos
Avançado
Nível
Experimental
Status
1

⚠️ Status experimental

Primeira coisa que precisa ficar absolutamente clara: o target iOS existe no repo, mas NÃO é produto shipável. É work-in-progress. PRs que assumem o contrário causam estrago.

🛑 O que NÃO existe no iOS

  • Core Rust on-device. Nada de domínios, RPC local, persistência. iOS é puro cliente.
  • CEF. Sem Chromium embedded. Sem CDP. Sem scanners.
  • Webview accounts. Não há provider integration on-device.
  • Build na App Store. Sem TestFlight público no momento.

O que existe

app/src/pages/ios/          // telas iOS-only (React)
app/src/components/ios/     // componentes nativos do app iOS
app/src/services/transport/ // LanHttp | Tunnel | CloudHttp
app/src/lib/tunnel/         // crypto XChaCha20-Poly1305 + X25519
packages/tauri-plugin-ptt/  // Swift + Rust, iOS only
src/openhuman/devices/      // domain devices no core desktop
docs/ios/SETUP.md           // guia de build

🔗 Dependência crítica

Pareamento end-to-end requer o backend PR tinyhumansai/backend#709 mergeado e deployed. Sem isso, tunnel socket.io não fecha contrato e o iOS fica preso.

Conceito-chave
Não-shipping
Conceito-chave
Cliente puro, sem core local
Conceito-chave
Depende backend #709
Conceito-chave
3 transports selecionáveis
2

🚀 LanHttpTransport

O caminho feliz. Quando iPhone e Mac estão na mesma rede WiFi, o app vai direto: HTTP ao core desktop na porta que ele anunciou. Latência mínima, zero servidor no meio.

📡 Fluxo

  1. Desktop anuncia {ip, port, token} durante pareamento (codificado no QR).
  2. iOS guarda em ConnectionProfile local (Keychain).
  3. Toda chamada: POST http://{ip}:{port}/rpc com Authorization: Bearer {token}.
  4. Falhou? Bumpa para o próximo transport (Tunnel).

✓ Vantagens

  • Latência sub-10ms tipicamente
  • Zero dependência de backend
  • Funciona offline (sem internet)

✗ Limitações

  • iPhone tem que estar na mesma LAN
  • IP pode mudar (DHCP renovou)
  • Rede corporativa bloqueia client isolation
Conceito-chave
ConnectionProfile
Conceito-chave
Caminho feliz
Conceito-chave
Fallback automático
Conceito-chave
Bearer token persistido
3

🔐 TunnelTransport — E2E XChaCha20

Quando a LAN falha (corporate, CGNAT, longe de casa), o iOS abre um tunnel via socket.io no backend. O backend é só relay: o conteúdo passa criptografado end-to-end com XChaCha20-Poly1305 sobre key agreement X25519. Backend é carteiro cego.

Por que XChaCha20-Poly1305 + X25519

X25519

ECDH em curva Curve25519. Constant-time, sem parâmetros frágeis. Cada lado tem keypair, derivam shared secret sem nunca expor chaves privadas.

XChaCha20-Poly1305

AEAD com nonce de 192 bits — basicamente impossível colidir mesmo com nonces aleatórios. Mais rápido que AES em ARM sem AES-NI (iPhone).

Anatomia de uma mensagem

{
  "channelId": "ch_abc123",        // roteamento (claro)
  "nonce":     "<24 bytes b64>",    // aleatório por msg
  "ct":        "<ciphertext + tag>", // payload + auth tag
}
// Backend lê só channelId. ct é opaco.

🔒 O que o backend NÃO vê

  • → Conteúdo de mensagem, voz, contexto, prompts.
  • → Qual RPC method está sendo chamado.
  • → Qual domain do core está sendo acessado.
  • → Resposta do core para o iOS.

Backend vê: channelId (necessário para rotear), timestamps, tamanhos de payload. Metadata mínimo.

Conceito-chave
Backend = carteiro cego
Conceito-chave
XChaCha20 nonce 192-bit
Conceito-chave
X25519 ECDH ephemeral
Conceito-chave
app/src/lib/tunnel/
4

☁️ CloudHttpTransport — último recurso

LAN morreu E tunnel está fora do ar. Antes de desistir, o iOS tenta HTTP direto ao backend cloud. Features limitadas, mas o app não fica preto.

Ordem de fallback

1. LanHttp

tenta primeiro

2. Tunnel

se LAN falhar

3. Cloud

último recurso

⚠️ Trade-offs do Cloud

  • → Latência alta (passa por servidor).
  • → Features que precisam de core desktop (RPC pesado, agente em loop) ficam limitadas.
  • → Conteúdo sensível pode passar pelo backend conforme implementação — não tem garantia E2E como o Tunnel.
  • → É graceful degradation, não substituto.

💡 Como o app escolhe

A ConnectionProfile persiste qual transport funcionou por último e tenta na ordem. Health check periódico em background promove de volta para LanHttp/Tunnel quando ficam saudáveis — usuário não precisa fazer nada.

Conceito-chave
Graceful degradation
Conceito-chave
Health check automático
Conceito-chave
sessionToken auth
Conceito-chave
Features reduzidas no Cloud
5

🎙️ PTT plugin — Swift + Rust

Push-to-talk no iOS não é só pedir microfone. Apple tem framework dedicado (PushToTalk.framework) que exige entitlement, ciclo de vida próprio e UI nativa de canal. Por isso o packages/tauri-plugin-ptt/.

Arquitetura do plugin

packages/tauri-plugin-ptt/
├── src/                  // Rust — invoke surface, eventos
│   └── commands.rs       // #[tauri::command] expostos ao JS
├── ios/                  // Swift bridge
│   ├── PTTPlugin.swift   // PTChannelManager + delegate
│   └── AudioSession.swift
└── permissions/

O que o plugin entrega

  • Captura áudio com tela bloqueada
  • UI nativa de canal (Lock Screen + Control Center)
  • Audio routing automático (Bluetooth, fones)
  • Background audio sem app aberto

O que ELE exige

  • !Entitlement com.apple.developer.push-to-talk (Apple aprova caso a caso)
  • !NSMicrophoneUsageDescription em Info.plist
  • !APNs setup para wake-up via push
  • !iOS 16+

⚠️ Por que não usa só AVAudioSession

Antes do PushToTalk.framework, apps tipo walkie-talkie tinham que rodar VoIP em background — bateria horrível e Apple começou a banir. O framework dedicado resolve: kernel scheduler trata como prioritário, mas só ativa quando você "abre o canal".

Conceito-chave
PushToTalk.framework
Conceito-chave
Entitlement especial
Conceito-chave
PTChannelManager
Conceito-chave
Swift bridge → invoke
6

📷 Pareamento por QR

É o único momento em que desktop e iPhone trocam chaves. Errar aqui = sessão sem E2E. Fluxo é o seguinte:

1

Desktop pede pairing ao backend

Settings > Devices > "Pair"

openhuman.devices_create_pairing
→ backend emite { channelId, pairingToken, sessionToken }
2

Desktop renderiza QR

Payload do QR contém:

{ cid: channelId, pt: pairingToken, cpk: corePublicKey, rpc?: lanUrl }
3

iOS escaneia (precisa NSCameraUsageDescription)

Decodifica payload, gera próprio keypair X25519, calcula shared secret com cpk.

4

iOS conecta ao backend tunnel

socket.emit('tunnel:connect', {
  role: 'client',
  channelId: cid,
  pairingToken: pt,
  clientPublicKey: ipk,
})
5

Backend amarra os dois lados ao mesmo channelId

Daqui pra frente é só relay. Toda mensagem criptografada com o shared secret.

Sessão E2E ativa

iOS persiste ConnectionProfile com chaves, IP da LAN (se veio no QR), sessionToken. Próximas conexões começam direto, sem QR.

⚠️ Falhas comuns

  • → Esqueceu de copiar NSCameraUsageDescription para Info.plist gerado por tauri ios init — câmera bloqueia silenciosamente.
  • → Backend #709 não está deployed — tunnel:connect retorna erro genérico.
  • pairingToken expira (TTL curto). Gere QR novo.
  • → Relógio do iPhone desincronizado > 5min — token JWT vira inválido.
Conceito-chave
devices_create_pairing RPC
Conceito-chave
cid + pt + cpk no QR
Conceito-chave
Shared secret single use
Conceito-chave
role: 'client' vs 'host'

Resumo do módulo

Status experimental — cliente puro, sem core local, depende backend #709.
LanHttpTransport — caminho feliz, mesma LAN, latência mínima.
TunnelTransport — XChaCha20-Poly1305 + X25519, backend cego.
CloudHttpTransport — graceful degradation, features limitadas.
PTT plugin — Swift + Rust, exige entitlement Apple, PTChannelManager.
Pareamento QR — único momento de troca de chaves, errar = sem E2E.

Próxima trilha:

Trilha 5 — Operação