🧩 Controller pattern
Toda funcionalidade do OpenHuman que precisa ser chamada de fora — do React, do CLI, de testes — passa por controllers. A regra é simples e única: cada domínio em src/openhuman/<dom>/ declara handlers em rpc.rs e expõe metadados em schemas.rs, registrados num registry central wired em src/core/all.rs.
⚡ Por que registry, não match gigante
Antigamente src/core/jsonrpc.rs tinha um match method { ... } com todos os domínios. Crescia O(n) por feature, gerava merge conflicts, e impedia geração automática de tipos. O controller registry trocou isso por data-driven dispatch.
- •Adicionar método: tocar 2 arquivos do domínio, zero linha no transport.
- •CLI e JSON-RPC compartilham 100% do dispatch — sem desync.
- •Schemas servem para validação, autocomplete e documentação.
Estrutura de um domínio
src/openhuman/cron/ ├── mod.rs // export-only ├── ops.rs // lógica de negócio ├── store.rs // persistência ├── types.rs // tipos do domínio ├── rpc.rs // handlers async pub fn ... ├── schemas.rs // ControllerSchema + registro └── bus.rs // EventHandler impl
📦 RpcOutcome<T>
É o Result do core. Tipo de retorno padronizado para todos os controllers: carrega o valor de sucesso OU um erro estruturado serializável. Sem ele, cada handler inventa formato próprio e o coreRpcClient do frontend vira um if/else infinito.
O contrato
pub enum RpcOutcome<T> { Ok(T), Err(RpcError), } // No handler do domínio: pub async fn create_job(params: CreateJobParams) -> RpcOutcome<Job> { match store::insert(params).await { Ok(job) => RpcOutcome::Ok(job), Err(e) => RpcOutcome::Err(RpcError::storage(e)), } }
✓ O que FAZER
- ✓Retornar
RpcOutcome::Errcom mensagem útil para o usuário - ✓Mapear
?do Rust paraRpcErrorviaFrom - ✓Logar o erro original (verbose) antes de devolver versão sanitizada
✗ O que NÃO fazer
- ✗
panic!()dentro de handler — derruba o tokio task - ✗Devolver
Result<Value, String>ad-hoc - ✗Vazar stack trace ou secret em
RpcError.message
📋 Schemas e registro
O schemas.rs de cada domínio é onde os metadados viram código. ControllerSchema descreve nome, descrição e campos do método; RegisteredController junta schema com handler. O resultado: o transport não precisa saber NADA sobre o domínio.
Esqueleto do schemas.rs
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
ControllerSchema {
name: "openhuman.cron_create_job".into(),
description: "Create a scheduled job".into(),
fields: vec![
FieldSchema::required("name", TypeSchema::String),
FieldSchema::required("cron", TypeSchema::String),
],
},
// ...
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController::new("openhuman.cron_create_job", handle_create_job)]
}
fn handle_create_job(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { rpc::create_job(serde_json::from_value(params.into())?).await.into() })
}
💡 Dica prática
Naming convention é rígida: openhuman.<namespace>_<function>. Underscore separa namespace de função; ponto separa app de método. Errar isso quebra autocomplete e a busca em about_app.
🌉 core_rpc_relay — por que invoke e não fetch
O core escuta em http://127.0.0.1:<port>/rpc com bearer auth. Você poderia usar fetch() direto do React — e vai funcionar em dev. Mas em produção, com headers customizados (Authorization), o browser dispara CORS preflight (OPTIONS) antes da chamada real. Isso quebra silenciosamente.
✗ fetch direto (ERRADO)
fetch('http://127.0.0.1:9001/rpc', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); // → OPTIONS preflight → CORS error
✓ invoke (CORRETO)
import { invoke } from '@tauri-apps/api/core'; const result = await invoke('core_rpc_relay', { method: 'openhuman.cron_create_job', params: { name, cron }, }); // → IPC nativo → core, sem CORS
Por que IPC nativo é melhor
- →Token
OPENHUMAN_CORE_TOKENnunca aparece em devtools network panel. - →Tauri injeta a porta do core em runtime — frontend não precisa adivinhar.
- →Sem preflight, sem CORS, sem MIME games.
- →
coreRpcClientembrulha tudo isso — você nunca chamacore_rpc_relaydireto.
📡 Event bus — broadcast tipado
RPC é 1-pra-1, request/response. Mas muito do core é reativo: o cron disparou, agora todo mundo que se interessa precisa saber. Para isso existe o event bus singleton em src/core/event_bus/ — pub/sub tipado sobre tokio::broadcast.
Define o evento
src/core/event_bus/events.rs
pub enum DomainEvent { CronFired { job_id: String, ts: i64 }, // ... }
Publica de qualquer lugar
publish_global(DomainEvent::CronFired { job_id, ts }).await;
Subscriber implementa EventHandler
impl EventHandler for WebhookRequestSubscriber { fn name(&self) -> &str { "webhooks::request" } fn domains(&self) -> Option<&[Domain]> { Some(&[Domain::Cron]) } async fn handle(&self, ev: &DomainEvent) { /* ... */ } }
Registra no startup, guarda o handle
let _sub: SubscriptionHandle = subscribe_global(handler).await;
// drop = cancela. RAII.
💡 Filtre por domínio
Implementar domains() evita que seu handler seja invocado para TODO evento do sistema. Crons em Memory, mensagens em Channel — o bus despacha milhares por segundo; sem filtro, você processa lixo.
🎯 NativeRegistry — request/response tipado interno
Tem casos em que dois módulos precisam conversar 1-pra-1, in-process, com handles vivos: passar um mpsc::Sender, um Arc<Mutex<T>>, um oneshot::Sender. JSON-RPC não serve — esses tipos não são Serialize. Para isso existe o NativeRegistry.
Padrão de uso
// 1. Define request/response no domínio. Send + 'static, NÃO Serialize. pub struct StartScannerReq { pub account_id: String, pub events_tx: mpsc::Sender<ScanEvent>, } pub struct StartScannerResp { pub handle: Arc<ScannerHandle> } // 2. Registra no startup, keyed por method string register_native_global( "webview_accounts.start_scanner", handler, ).await; // 3. Chama de qualquer outro módulo let resp: StartScannerResp = request_native_global( "webview_accounts.start_scanner", StartScannerReq { account_id, events_tx }, ).await?;
✓ Use NativeRegistry quando
- ✓Precisa passar canais Tokio entre domínios
- ✓Trabalho é estritamente interno (não exposto ao renderer)
- ✓Quer testes plugáveis (sobrescrever método)
✗ NÃO use quando
- ✗Quer expor ao TypeScript — use controller registry
- ✗Muitos subscribers escutando — use event bus broadcast
- ✗Quer durabilidade ou retry — não tem fila persistente
🧪 Tip: override em testes
Re-registrar o mesmo método via register_native_global substitui o handler antigo. É como você mocka dependências de um domínio em testes sem mexer no código de produção. Para isolamento total, construa um NativeRegistry::new() fresco.
✅ Resumo do módulo
Próximo módulo:
4.2 — 🖼️ CEF e webview accounts