🌍 i18n strict — parity em 12 locales
Toda string visível no app passa por useT(). Não existe
string hard-coded em JSX, label=, placeholder= ou aria-label=.
Adicionar uma chave é mais trabalhoso do que parece — e o CI vai pegar se você esquecer.
⚠️ Workflow obrigatório (uma chave nova)
- Adicionar em
app/src/lib/i18n/en.ts(source of truth) - Adicionar no chunk correspondente
en-N.ts(1 a 5) - Adicionar a MESMA chunk em TODOS os 12 locales (
pt-N.ts,es-N.ts, …) - Pode usar valor inglês como placeholder em locales não-inglês — tradutores ajustam depois
Esqueceu um locale? pnpm i18n:check falha no CI.
🗺️ Os 12 locales
✓ Correto
const t = useT();
<button aria-label={t('chat.send')}>
{t('chat.send_label')}
</button>
✗ Errado
<button aria-label="Send"> Send </button> // CI: i18n violation
🚫 NO dynamic imports em produção
import(), React.lazy(() => import(...)) e await import(...) são
banidos em app/src de produção. Bundle precisa ser
previsível — sem chunk loading runtime que pode falhar offline.
✗ Banido
// ❌ Dynamic import direto
const mod = await import('./heavy');
// ❌ React.lazy
const Heavy = React.lazy(
() => import('./Heavy')
);
// ❌ Lazy route
const Settings = lazy(
() => import('./Settings')
);
✓ Aceito
// ✅ Static import
import { Heavy } from './Heavy';
// ✅ Guard em runtime
try {
Heavy.maybeInit();
} catch { /* opt-out */ }
// ✅ import type sempre OK
import type { Foo } from './types';
💡 Exceções permitidas
- • Harness Vitest em
*.test.ts,__tests__/,test/setup.ts - • Ambient
typeof import('…')em arquivos.d.ts - • Config files (ex.:
tailwind.config.jscom JSDoc)
Fora dessas, é bug. Para "heavy optional paths" — static import + try/catch ou runtime check no call site.
🤔 Por que essa regra existe
O app é desktop (Tauri). Não tem CDN para chunk lazy. Se a chunk não carregar (corrupção, FS issue), a feature quebra silenciosamente. Static bundle = previsibilidade. Trade-off: bundle maior, comportamento determinístico.
📏 File size ≤ ~500 linhas
Filosofia Unix do projeto: módulos pequenos com responsabilidade afiada compostos por boundaries claros. Acima de ~500 linhas, dividir. Cada arquivo deve caber na cabeça.
🔧 Padrão de divisão por domínio Rust
src/openhuman/memory/ ├── mod.rs # só declara módulos + re-exports (light!) ├── types.rs # structs, enums, traits ├── store.rs # persistência ├── ops.rs # operações (lógica) ├── rpc.rs # handlers JSON-RPC ├── schemas.rs # ControllerSchema └── bus.rs # EventHandler subscribers
✓ Bom sinal
- ✓200-400 linhas por arquivo
- ✓Cada arquivo tem 1 responsabilidade nomeável
- ✓Você abre o arquivo certo sem hesitação
✗ Sinal de alerta
- ✗900+ linhas com tudo misturado
- ✗Nome do arquivo é genérico ("utils")
- ✗Você usa Ctrl+F antes de ler
🎛️ Controller-only exposure
Para expor features ao CLI e JSON-RPC, registre via controller registry.
Branches manuais em src/core/cli.rs ou src/core/jsonrpc.rs são proibidos —
reintroduzem o acoplamento que controllers vieram resolver.
📋 Checklist novo controller
- Criar
src/openhuman/<domain>/schemas.rscomschemas,all_controller_schemas,all_registered_controllers - Adicionar
handle_*fns delegando pararpc.rs - Em
mod.rs:mod schemas;+ re-export com prefixo do domínio - Wire em
src/core/all.rs - Remover qualquer branch antigo de
src/core/dispatch.rs
📐 Como fica em mod.rs
// src/openhuman/cron/mod.rs
mod ops;
mod rpc;
mod schemas;
mod store;
mod types;
mod bus;
pub use schemas::{
all_controller_schemas as all_cron_controller_schemas,
all_registered_controllers as all_cron_registered_controllers,
};
🚫 NÃO faça isso
// src/core/dispatch.rs
match method {
"openhuman.cron_list" => handle_cron_list(...), // ❌ NUNCA
"openhuman.cron_create" => handle_cron_create(...), // ❌ NUNCA
// ...
}
Acopla core/ ao domínio. Cresce sem limite. Controller registry existe pra remover isso.
📁 Light mod.rs — só índice
Regra: mod.rs declara módulos e re-exports. Nada mais.
Lógica vai em ops.rs, store.rs, types.rs. Novos arquivos em
src/openhuman/ root também são proibidos — tudo em subdiretório dedicado.
💡 Por quê
mod.rs é o "índice" do domínio. Quando alguém abre o módulo, quer ver imediatamente
o que existe lá dentro. mod.rs de 600 linhas com lógica esconde a estrutura — você
tem que ler tudo pra saber o que tem.
✓ mod.rs ideal
// src/openhuman/skills/mod.rs mod inject; mod ops_create; mod ops_discover; mod ops_install; mod ops_parse; mod schemas; mod types; pub use types::*; pub use schemas::*;
✗ mod.rs gordo
mod helpers;
pub struct Skill { ... } // ❌
pub fn install(...) { ... } // ❌
async fn do_thing() { ... } // ❌
// 400 linhas misturadas
⚠️ Layout obrigatório
Nova funcionalidade vai em subdiretório dedicado: openhuman/<domain>/mod.rs + siblings.
Não criar *.rs standalone em src/openhuman/ root. dev_paths.rs e
util.rs são grandfathered — não viram template.
📨 Event bus — owned types, não borrows
Tipos passados pelo event bus precisam ser Send + 'static e owned —
Arc, mpsc::Sender, oneshot::Sender, owned fields. Não Serialize (é
in-process, não JSON-RPC), não borrows (lifetimes não vivem em canal).
📋 Native request/response típico
// 1. Tipos owned, sem borrows
pub struct SendMessageRequest {
pub channel_id: ChannelId, // owned
pub text: String, // owned
pub reply: oneshot::Sender<Result>, // owned
}
// 2. Registrar no startup
register_native_global("channel.send", Arc::new(ChannelSendHandler::new()));
// 3. Chamar de qualquer lugar
let (tx, rx) = oneshot::channel();
request_native_global("channel.send", SendMessageRequest {
channel_id, text, reply: tx
}).await?;
let result = rx.await?;
✓ Tipos válidos no payload
- ✓
Arc<T>— shared ownership - ✓
String,Vec<T>— owned - ✓
mpsc::Sender/oneshot::Sender - ✓Trait objects
Arc<dyn Trait>
✗ Inválidos
- ✗
&str,&[T]— borrows - ✗Qualquer lifetime
'a - ✗Tipos não-Send (Rc, RefCell)
- ✗Esperar
Serialize(não é JSON)
💡 Naming convention
Método sempre "<domain>.<verb>" — ex.: "channel.send", "memory.upsert",
"skills.install". Subscriber tem name() retornando "<domain>::<purpose>"
(ex.: "cron::delivery").
🛡️ Webview zero-injection — sem JS novo
Webviews CEF que carregam origens de terceiros (whatsapp, slack, telegram, discord, browserscan) não recebem nenhum JavaScript novo. Tudo via CDP nativo. Esta regra é intencional — surface de ataque + fragilidade de scraping.
🚫 Proibições
- • Novos arquivos
.jsemapp/src-tauri/src/webview_accounts/ - • Append em
build_init_scriptouRUNTIME_JS - • Scripts via CDP
Page.addScriptToEvaluateOnNewDocumentouRuntime.evaluate - • Plugins que injetam JS por padrão (ex.:
tauri-plugin-openercominit-iife.js)
📋 Onde nova lógica vai
- •CEF handlers —
on_navigation,on_new_window,LoadHandler::OnLoadStart,CefRequestHandler::* - •CDP nativo —
Network.*,Emulation.*,Input.*,Page.*dos*_scanner/per-provider - •IPC Rust-side — nunca cruza pro renderer
⚠️ Plugins Tauri: audite sempre
tauri-plugin-opener injeta init-iife.js com um global click listener.
Para desligar:
// app/src-tauri/src/lib.rs
tauri::Builder::default()
.plugin(
tauri_plugin_opener::Builder::new()
.open_js_links_on_click(false) // ✅ opt-out
.build()
)
Qualquer plugin novo: audite se chama js_init_script. Se sim — configure pra não injetar.
💡 Limite real, não excessivo
Se uma feature realmente não dá pra fazer sem injetar (ex.: interceptar clique que o JS da página
preventDefaulta), o correto é surface da limitação — não shippar init script. Legado em
gmail/linkedin/google-meet é grandfathered: deve encolher, não crescer.
🧰 Resumo do Módulo
useT() sempre, 12 locales em parity, pnpm i18n:check.import()/lazy() proibidos em prod.ops/store/types.dispatch.rs.mod + pub use; subdir por domínio.Send + 'static, sem borrows, sem Serialize.🎉 Trilha 6 completa!
Você passou pelo bestiário, dominou debug logging e absorveu as dicas avançadas. Agora você sabe troubleshoot OpenHuman — e mais importante, sabe como não cair nas mesmas armadilhas.