⚠️ 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.
🚀 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
- Desktop anuncia
{ip, port, token}durante pareamento (codificado no QR). - iOS guarda em
ConnectionProfilelocal (Keychain). - Toda chamada:
POST http://{ip}:{port}/rpccomAuthorization: Bearer {token}. - 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
🔐 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.
☁️ 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
tenta primeiro
se LAN falhar
ú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.
🎙️ 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) - !
NSMicrophoneUsageDescriptionem 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".
📷 Pareamento por QR
É o único momento em que desktop e iPhone trocam chaves. Errar aqui = sessão sem E2E. Fluxo é o seguinte:
Desktop pede pairing ao backend
Settings > Devices > "Pair"
openhuman.devices_create_pairing
→ backend emite { channelId, pairingToken, sessionToken }
Desktop renderiza QR
Payload do QR contém:
{ cid: channelId, pt: pairingToken, cpk: corePublicKey, rpc?: lanUrl }
iOS escaneia (precisa NSCameraUsageDescription)
Decodifica payload, gera próprio keypair X25519, calcula shared secret com cpk.
iOS conecta ao backend tunnel
socket.emit('tunnel:connect', {
role: 'client',
channelId: cid,
pairingToken: pt,
clientPublicKey: ipk,
})
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
NSCameraUsageDescriptionpara Info.plist gerado portauri ios init— câmera bloqueia silenciosamente. - → Backend #709 não está deployed —
tunnel:connectretorna erro genérico. - →
pairingTokenexpira (TTL curto). Gere QR novo. - → Relógio do iPhone desincronizado > 5min — token JWT vira inválido.
✅ Resumo do módulo
Próxima trilha:
Trilha 5 — Operação