Arquitetura
Como o Monky é construído por dentro: os componentes, como eles conversam e por que as decisões foram tomadas desse jeito.
Esta página descreve o que existe hoje no código. Se você procura a especificação original do projeto — com MVP, fases e ideias futuras —, ela está em docs/especificacao-tecnica.md.
A ideia central: dois planos separados
Tudo no Monky parte de uma separação: o que o servidor controla e o que trafega direto entre as pessoas.
O servidor cuida de login, canais, chat, cargos e sinalização. No modo padrão, ele apresenta os participantes uns aos outros e sai da frente: voz, vídeo e tela trafegam P2P via WebRTC, sem passar por ele.
Isso tem duas consequências que explicam quase todo o resto do projeto:
- A banda do servidor quase não importa. Ele não carrega mídia, então um VPS modesto aguenta o grupo. O custo de banda fica com os participantes.
- A conversa não é legível pelo servidor. Mesmo quem hospeda não consegue ouvir a chamada — o WebRTC é criptografado ponta a ponta entre os pares.
As duas consequências valem para o modo padrão
Quem hospeda pode trocar o plano de mídia para o modo SFU, e aí as duas afirmações acima deixam de valer: o servidor passa a carregar toda a mídia do canal e a ter acesso ao conteúdo dela. O SRTP termina no mediasoup, que decifra os pacotes e os cifra de novo para cada destinatário — é assim que qualquer SFU funciona, e é o preço de não mandar o mesmo vídeo N vezes. Continua não existindo terceiro na jogada, já que o servidor é seu, mas o "nem quem hospeda consegue ouvir" é uma propriedade do P2P Mesh, não do Monky.
Os componentes
O repositório é um monorepo com workspaces npm:
| Workspace | O que é |
|---|---|
apps/client | O app Electron — a interface, e também o anfitrião quando você hospeda pelo próprio app. O mesmo renderer gera a versão web (npm run build:web) |
apps/server | O servidor: WebSocket, SQLite e o Monky CLI |
apps/light | Núcleo nativo headless em desenvolvimento; não é o cliente desktop publicado descrito nos guias de uso |
packages/shared | O contrato entre os dois: tipos do protocolo, validadores, limites e perfis de qualidade |
packages/dm | Amigos e mensagens diretas: o mesmo motor no desktop e na versão web, com criptografia e armazenamento fornecidos por cada um |
packages/bot-sdk | SDK, contratos de bot, conexão de voz, execução local e empacotamento do CLI de bots |
packages/shared é o que impede cliente e servidor de divergirem: os dois importam os mesmos tipos e os mesmos validadores.
A implementação e as limitações atuais do Light estão no README próprio. As seções de interface abaixo descrevem o cliente Electron.
O cliente
Três processos
O Electron separa o app em três contextos, e o Monky respeita essa separação:
O renderer roda com contextIsolation: true e nodeIntegration: false: a interface não tem acesso ao Node. Tudo que precisa do sistema operacional — escolher uma tela para compartilhar, ler o módulo nativo de áudio, mexer na bandeja — passa pela ponte window.api exposta pelo preload.
A ponte tem um contrato só, a PlatformApi (src/renderer/platform/api.ts). No desktop, quem a implementa é o preload; na versão web, é src/renderer/platform/web, com recursos do navegador (IndexedDB, WebCrypto, File System Access). Cada implementação declara suas capabilities. A interface é a mesma nas duas: o que a plataforma não tem, como hospedar servidor ou usar atalhos globais, aparece no mesmo lugar, bloqueado e marcado "Desktop". Dentro de uma janela ou modal, um aviso no topo explica e oferece baixar o app; fora delas, ativar o item abre esse convite. Uma regra só decide isso, featureAvailability (platform/capabilities.ts), e os controles bloqueados vêm de utils/desktopOnly.ts. Só a barra de título e os botões da janela do desktop somem na web.
Quando um recurso existe nas duas, a regra fica num motor só e cada plataforma fornece o armazenamento. As mensagens diretas usam packages/dm. O armazenamento dos miniapps no aparelho usa botDeviceStorageEngine.ts, em packages/shared: o desktop grava arquivos pelo processo main, e a web grava no IndexedDB da origem do app, nunca na dos miniapps.
Três barreiras protegem essa ponte. A Content-Security-Policy do index.html só executa scripts do próprio app, então um HTML injetado por engano não roda; um recurso de tipo novo (outro iframe, worker ou origem) precisa entrar nessa política. Nenhuma janela sai do próprio documento nem abre outras janelas, com exceção do Picture-in-Picture e dos links enviados ao navegador. E os canais sensíveis — identidade, backups, servidores hospedados, arquivos locais — só respondem à janela principal; o overlay recebe uma ponte própria, apenas com o que precisa para desenhar a chamada.
A interface não usa framework
Talvez a decisão mais incomum do projeto: o renderer é TypeScript e DOM puro. Não há React, Vue ou Svelte. As telas montam o próprio HTML com template strings e se re-renderizam.
SessionManager mantém conexão, chat, membros e miniapps separados por sessão de servidor. As stores da sessão ativa emitem no barramento de interface (appEvents); as de fundo atualizam seus dados por um barramento silencioso. Preferências e a chamada ativa têm escopo global. Trocar a sessão visualizada não equivale a encerrar as conexões.
As telas se inscrevem nos eventos relevantes; o diagrama mostra esse fluxo lógico, não uma única store compartilhando dados de todos os servidores:
Os eventos do barramento são tipados em core/appEventMap.ts: cada nome declara o que carrega, e quem emite ou assina com outro formato não compila. As mensagens do servidor chegam como message.<TIPO>, com o payload do mapa do protocolo (ServerToClientPayloads, no @monky/shared). As stores seguem três formatos:
- Por sessão (
serverStore,chatStore,botScreenStoreeParticipantManager): emitem no própriobus, que é oappEventsna sessão visível e o barramento silencioso nas demais. O resto do app as usa por um proxy que aponta para a sessão ativa. - Globais (
settingsStore,connectionStoreevoiceStore): emitem direto noappEvents, e o evento diz o que mudou; osettings.updated, por exemplo, traz as chaves alteradas. AvoiceStoretambém muda durante eventos de outros servidores, por isso notifica poremitOutsideRouting. - Modelos de tela (
favoritesStoreeCommunityFeed): avisam as telas que os usam por umsubscribe()próprio. AdmStoretem um barramento só dela, tipado porDmEvents.
Quem aplica as mensagens do servidor às stores são módulos por domínio, ligados uma vez na inicialização: core/serverStateEvents.ts (membros, cargos, canais e configurações), core/chatEvents.ts e core/voiceEvents.ts. A tela de um servidor (views/MainView.ts) compõe as partes de views/main/: cabeçalho, árvore de canais, membros, cartão do usuário e avisos da chamada.
Uma tela que consulta um servidor por requisição, como a auditoria e o monitor, fica presa ao servidor, à conexão e ao membro do momento em que abriu por uma SessionBinding (core/sessionBinding.ts): quando um deles muda, ela para em vez de misturar dados de outro servidor. BoundRequests aposenta cada requisição quando ela termina e as pendentes quando a tela fecha, e watchVisibleSession (core/visibleSession.ts) revalida o acesso quando o servidor muda e fecha a tela quando ele deixa de ser o visível.
O kit de interface
As peças que toda tela repete ficam num lugar só, e uma tela nova as usa em vez de reescrevê-las:
- Casca de modal (
utils/modalShell.ts):openModal({ card, ... })põe o cartão na pilha de modais e cuida do resto. Um únicokeydownem captura nodocumentsó age quando o modal está no topo e sem um popup próprio aberto: Escape fecha (ou limpa antes um campodata-clear-on-escape) e Tab circula dentro do cartão. O cartão sempre é umdialogmodal com nome; o foco começa no primeiro controle abaixo do cabeçalho e volta, ao fechar, a quem abriu. A casca também fecha o modal pelos botõesdata-modal-closee por um clique que começa no fundo;canClose(motivo)o segura durante uma operação. Quando o servidor visível muda (closeWithSession) ou osignaldo dono termina, o modal sai na hora, sem animação de saída e com os seus popups: o conteúdo de outro servidor não pode ficar por cima do novo. Todo listener do conteúdo usashell.signal. Uma tela que redesenha escreve dentro deshell.card, nunca o troca: enquanto um diálogo filho está aberto, a pilha esconde o cartão de baixo, e um cartão novo escaparia disso. A ordem entre modais é da pilha (utils/modalSurface.ts), nunca de umz-indexde cada modal.openCommunityModale o diálogo de captura de atalho (ShortcutCaptureDialog) são camadas finas sobre a casca. - Feedback (
utils/feedback.ts): erro de operação vai para o toast de alerta compartilhado comreportError(mensagem, { field, focus, log, error }), que também marca o campo comaria-invalidaté a próxima edição. Nunca fica uma mensagem de erro solta no layout. Uma carga que falhou é outra coisa: o conteúdo dá lugar a um aviso com "Tentar novamente" (renderLoadingError, emutils/loadingSkeleton.ts).copyTexteopenExternalUrlconfirmam ou explicam no toast.useAppValidation(form, signal)troca o balão nativo do navegador, que segue o idioma do sistema, por uma explicação no idioma do app. Duas exceções são de propósito: uma falha terminal de mídia que chega sem uma ação da pessoa (reconexão de voz, microfone, câmera, captura de tela) continua num diálogo, para não passar despercebida, e um erro do palco em tela cheia fica dentro do elemento em tela cheia, porque um toast nobodynão apareceria. - Controles de escolha (
utils/formControls.ts): liga/desliga é semprerenderSwitch({ id, checked, label | labelledBy, ... }), um checkbox escondido comrole="switch"e nome, e o foco de teclado aparece no trilho em qualquer lugar. Uma escolha entre poucas opções vira cards (renderChoiceCardseselectChoiceCard): botões comaria-pressed, no estilo.input-mode-card, nunca radio buttons. O modo de voz P2P/SFU usa esses cards porrenderVoiceModeCards(utils/voiceModeInfo.ts). - Abas de configurações (
views/settings/settingsLayout.ts): as Configurações e as Configurações do servidor declaram as abas uma vez, numa tabela (SettingsTabEntry: id, ícone, rótulo), e montam a barra lateral e o título comrenderSettingsTabButtonserenderSettingsTabLabel. Nas Configurações, cada aba da tabela também diz como se monta, se liga, se mostra, se esconde e se limpa. - Wizards da comunidade (
views/community/wizardShell.ts): enquete, formulário e evento montam o mesmo quadro commountWizard. O indicador de etapas e o rodapé ficam parados enquanto a etapa desliza entre eles, para a frente ou para trás, e cada etapa nova começa do topo com o título em foco. Voltar e Avançar esperam o deslize terminar. Como o rodapé não sai do lugar, o segundo clique de um clique duplo cai no botão que o primeiro acabou de trocar; esse clique, que o sistema conta como parte do mesmo gesto (event.detail2 ou mais), é ignorado. Assim um clique duplo anda uma etapa só e nunca publica o que ninguém revisou, com ou sem movimento reduzido; o teclado e um clique novo funcionam normalmente. Cada wizard diz quando uma etapa pode ser deixada: com o botão desabilitado (ready) ou com uma explicação no toast (leave). O rascunho fecha na hora quando o servidor visível muda. - Seletor de público (
views/ResourceAudiencePicker.ts): quem pode ver um recurso da comunidade ou assistir a uma transmissão privada. É uma lista com busca que também abre pelo teclado (setas no campo fechado), respeita os limites deAUDIENCE_LIMITS, do@monky/shared, e aceita textos próprios e a chave liga/desliga de quem o usa (externalSwitchcomsetPrivate). - Volume de um tile do palco (
views/StageVolumeControl.ts): o botão de mudo com o controle deslizante vertical de 0 a 200% acima dele, o mesmo na tela compartilhada e no miniapp. Abre ao passar o mouse e pelo teclado (o Tab vai do botão ao controle); Escape fecha, e o áudio mudo o mantém fechado. - Movimento: entrada e saída por
utils/surfaceMotion.ts, a pilha de modais porutils/modalSurface.ts, expansões porsetSurfaceVisible(utils/surfaceVisibility.ts; um painel que desdobra pela própria transição CSS, como os do soundboard, chamarevealWhenUnfoldedao abrir, para terminar à vista do mesmo jeito), etapas que trocam o cartão inteiro porreplaceModalStepe etapas dentro da área fixa de um wizard porslideStepContent(ambos emutils/modalSteps.ts), com direção de avançar e voltar. Troca de aba é umaview: só opacidade. Rolagem visível passa porutils/scroll.ts. Todos respeitamprefers-reduced-motion. - Painéis redimensionáveis (
utils/resizableEdge.ts): a borda que a pessoa arrasta para alargar um painel vira um separador de janela, com papel e limites para leitores de tela, setas, Home e End, e a largura salva e restaurada. A lista de canais e o chat de voz a usam.
Os serviços
Cada responsabilidade grande do cliente vive em uma classe própria, em src/renderer/core/:
| Serviço | Responsabilidade |
|---|---|
NetworkClient | WebSocket, autenticação, heartbeat e reconexão |
WebRtcManager | Orquestração de mídia comum, publicações locais e conexões P2P; delega o transporte SFU |
webrtc/SfuClientEngine | Transporte SFU: capacidades negociadas, producers, consumers e reconexão |
AudioProcessor | Microfone, supressão de ruído, detecção de fala |
VideoService | Captura compartilhada de câmera, efeitos locais e registro das telas publicadas |
ParticipantManager | Quem está online, em qual canal e com qual estado |
SoundboardService | Sons e atalhos do soundboard |
KeybindService | Atalhos globais |
AttachmentUploader | Upload de anexos do chat |
UpdateService | Verificação e aviso de atualização |
Compartilhamento de tela: comportamento comum, transporte separado
Compartilhar uma tela é a mesma funcionalidade nos dois modos. O transporte não deve ter uma segunda implementação da captura, das preferências de qualidade ou das regras de codec.
| Responsabilidade | Onde fica |
|---|---|
| Registro local das telas publicadas e de suas prévias | VideoService; captura, encoder e áudio pertencem à fonte nativa |
| Parada/substituição nos controles, áudio associado e atualização da sessão correta | screenShareControls.ts, usado pelo seletor, views e saída/troca de chamada |
| Início, cancelamento e retirada de cada fonte | startNativeScreenShare() e removeNativeScreenSource() do WebRtcManager, executados por webrtc/NativeScreenController.ts (fontes locais em webrtc/nativeScreen/LocalScreenSources.ts) |
| Intenção de assistir, independente da view e da preferência de mute | VoiceStore, ligada à sessão da chamada e aplicada pelo WebRtcManager aos dois transportes |
| Codec e modo de codificação da tela | Preferências de qualidade, sondadas por core/screenEncoding.ts e verificadas na admissão da fonte nativa |
| Limites de bitrate/FPS e preferência de adaptação de microfone e câmera | webrtc/mediaEncodingPolicy.ts, usado por P2P e SFU |
| Ordem de preferência dos codecs da câmera | webrtc/codecPreferences.ts, com capacidades fornecidas por cada transporte |
| Rota ICE em uso (ping, indicador de relay e diagnóstico) | webrtc/rtcStats.ts, usado por P2P e SFU; a chamada mede uma vez e o palco e a barra lateral só exibem |
| Alterações serializadas de parâmetros RTP | webrtc/rtpSenderParameters.ts: atualizações simultâneas não sobrescrevem a transação uma da outra |
| Recepção e associação da mídia remota à interface | webrtc/RemoteMediaRouter, comum aos dois modos |
| Captura/encoder/transporte nativos e ownership no Main | nativeScreenSharing.ts e native/screen-share |
| Assinaturas e apresentação de tela nativa ou recepção Chromium compatível | webrtc/nativeScreen/RemoteScreenPresentations.ts (reconexão automática em RewatchPolicy.ts) e webrtc/BrowserScreenSubscription.ts |
O renderer não publica telas: a conexão de voz leva só microfone e câmera. Cada tela é capturada, codificada e enviada pela fonte nativa, em conexões próprias, tanto em P2P quanto em SFU. O Chromium participa só como receptor opcional dessas telas, quando escolhido nas configurações (BrowserScreenSubscription, por P2P ou SFU).
Na versão web, o mesmo contrato de comandos e eventos da fonte nativa é respondido por src/renderer/platform/web/webScreen: a captura vem da janela do próprio navegador (getDisplayMedia), cada perfil pedido é uma cópia da captura reduzida pelo navegador, e o envio é H.264 por conexões próprias para cada espectador em P2P ou por um par de producers de tela no SFU, com o mesmo protocolo monky-native-screen-p2p e os mesmos dados de rendição. O formato padrão é o H.264 Main, de imagem melhor, que só o Chrome, o Edge e o desktop assistem; quem publica pode escolher o Constrained Baseline (42e0.., packetization-mode 1), que todo receptor decodifica, inclusive o Firefox e o Safari. O codec fica fixo pelo setCodecPreferences em P2P e pelo codec do producer no SFU, mesmo quando o navegador usaria o encoder de hardware. O renderer acima dele não muda; o seletor é a janela do navegador (views/screenShare/BrowserScreenSharePicker.ts), e a versão web assiste sempre com o receptor do navegador. Um navegador sem o perfil ou o codec da transmissão (o Firefox diante do H.264 Main ou do AV1) recebe uma mensagem que nomeia o formato, e não a falha genérica de conexão.
O app desktop oferece o mesmo par de formatos em Qualidade e compartilhamento, com Melhor qualidade como padrão. O formato é um perfil H.264 e não restringe o codec: o Automático continua escolhendo o AV1 quando disponível. Quando a tela sai em H.264 no Compatível, o Main passa --h264-profile=constrained-baseline ao host de captura: x264, NVENC e AMF usam o perfil baseline do OBS, e o VideoToolbox usa H264_ConstrainedBaseline_AutoLevel. O host e o motor marcam o constraint_set1 de um SPS Baseline sem grupos de slices nem imagens redundantes, que é Constrained Baseline (H.264 A.2.1). O motor (videoInput: 'encoded-h264-cb') negocia só esse perfil e atende receptores em qualquer nível anunciado, porque navegadores anunciam o nível 3.1 padrão e decodificam até o 5.2; por isso a fonte compatível não passa do nível 5.2.
O seletor só usa a captura nativa e explica o motivo quando ela não está disponível: não existe alternativa pela captura do Chromium. Fechar o seletor cancela uma aquisição pendente sem encerrar telas já publicadas. O áudio da tela pertence à fonte nativa que o captura, então encerrar a tela não pode produzir um anúncio tardio de áudio ativo. O helper de controles captura a sessão da chamada, em vez de enviar atualizações ao servidor que estiver visível quando uma operação assíncrona terminar.
Os valores do perfil são tetos por envio, não garantias de FPS nem um orçamento agregado de upload. P2P pode transmitir a mesma tela para vários destinatários; SFU normalmente a envia uma vez ao servidor. Esses tetos não prometem 1080p120 sustentados.
O caminho nativo usa libobs/WGC → AMF H.264 → WebRTC nativo no Windows x64 qualificado, sem decodificar e recodificar o vídeo no transmissor. A escala com stretch acontece antes do encoder. No receptor Windows, Media Foundation decodifica e SharedTexture entrega os frames ao palco e à sobreposição. Receptores Chromium negociam o perfil H.264 real antes de aceitar a assinatura.
No macOS 14+ (arm64/x64), ScreenCaptureKit captura janelas/monitores, Core Image/Metal redimensiona; VideoToolbox codifica H.264 e libaom codifica AV1 por software. O encoder AV1 lê NV12 em C++ fora do Renderer, preserva o timestamp Mach e aplica bitrate/keyframes na mesma fila serial da captura. O RTC isolado em processo transporta o vídeo em P2P/SFU; VideoToolbox decodifica H.264 e o decoder AV1 por software cobre recepção desse codec. Direitos Mach transferem IOSurfaces ao Main, com liberação condicionada à devolução real das referências do Chromium. O áudio original usa timestamps Mach, não QPC. Contadores por decoder e relógios nativos medem FPS sem confundir o perfil solicitado com a taxa observada.
Cada perfil demandado tem seu próprio pipeline; espectadores do mesmo perfil compartilham captura/encoder, mas não a autorização de assistir. Há no máximo quatro perfis por fonte e 16 espectadores por perfil. Sem demanda, o pipeline é fechado e só o descritor da fonte permanece. A nova assinatura cria owners novos, sem reutilizar engines retiradas. IPC centralizado, validação no Main e comprovantes privados de fechamento preservam a posse dos recursos.
O que fica salvo na sua máquina
O cliente usa localStorage para preferências simples, IndexedDB para os efeitos e a imagem de fundo da câmera, e armazenamento nativo para a identidade. São dados com finalidades e garantias diferentes.
| Chave | Conteúdo |
|---|---|
monky_settings | Preferências: qualidade, dispositivos, volumes, atalhos, soundboard |
monky_nickname / monky_avatar | Sua identidade visual |
monky_saved_servers | Servidores que você salvou para reconectar, com a chave de identidade fixada de cada um |
monky_created_servers | Servidores que você criou nesta máquina |
monky_favorites | Favoritos locais de sons por caminho completo e de servidores por endereço/porta |
monky_device_id | Identifica este dispositivo (permite a mesma pessoa em dois aparelhos) |
monky_language | Idioma da interface |
No IndexedDB monky-camera-effects, preferências e uma imagem normalizada ficam no mesmo registro transacional. A falha de persistência não troca a preferência em memória; dados corrompidos não são interpretados como consentimento para transmitir a câmera sem efeito.
A separação automática usa Robust Video Matting (RVM/MobileNetv3) com TensorFlow.js, modelo e pesos empacotados, sem CDN ou envio de frames a uma API. Inferência, composição e chroma key rodam em um worker com OffscreenCanvas, com um frame em trânsito por vez. MediaStreamTrackProcessor lê diretamente a captura; o fallback por elemento de vídeo emite aviso quando a API não está disponível. Chroma, composição e desfoque separável usam shaders WebGL2, sem leitura ou loop JavaScript dos pixels do vídeo inteiro. O desfoque exclui a pessoa antes da filtragem e normaliza o resultado pelo peso de fundo disponível, sem ampliar a imagem. Isso evita espalhar as cores da pessoa para o contorno ou desenhar uma segunda silhueta deslocada. O RVM recebe RGB na resolução completa e mantém quatro estados recorrentes. A razão interna é min(1, 480 / max(largura, altura)); cor do primeiro plano e alpha saem na resolução completa, sem download para a CPU. A composição compartilha o contexto WebGL2 do TensorFlow e restaura seu estado. O alpha não recebe o limiar ou a suavidade do segmentador antigo; os valores legados continuam armazenados, mas a interface informa que não são aplicados. Sem WebGL2, os efeitos de IA falham explicitamente. O chroma físico mantém o compositor Canvas2D com aviso no log; não há troca silenciosa do modelo de IA. Perda do contexto GPU durante o processamento interrompe a câmera, sem publicar frames crus ou trocar silenciosamente de backend. O modelo é criado sob demanda e reutilizado entre desfoque, cor e imagem. Reconfiguração ou mudança de tamanho reinicia a memória temporal; no chroma o modelo fica ocioso, sem inferência, preservando os shaders compilados. A configuração e o primeiro frame têm até 60 segundos cada para inicialização e compilação fria dos shaders, que pode ultrapassar 30 segundos no Metal. O primeiro frame de uma nova resolução também recebe esse prazo de compilação. Os frames seguintes mantêm watchdog de 8 segundos. Durante a preparação nenhum frame é publicado, e cancelar a captura não espera esses prazos. Os testes CI usam D3D11 WARP no Windows e Metal no macOS ARM, mantendo inferência real e sem alterar a seleção gráfica do aplicativo. Desativar efeitos ou encerrar a captura libera leitor, tensores, texturas, contexto e worker. Resolução e cadência seguem o perfil selecionado. O limitador opcional, desligado por padrão, restringe ambas a 1280 × 720 e 30 FPS, sem ampliar uma captura menor ou duplicar frames para compensar limitações de captura ou processamento. CameraPublication coordena substituições e falhas com os publishers P2P/SFU; a captura pertence ao VideoService, não ao preview ou ao producer.
A chave privada fica de fora dessa lista de propósito. Ela é o que prova quem você é (veja Autenticação) e nunca chega ao renderer: mora em identity.json, na pasta userData, cifrada com o safeStorage do Electron — o cofre do sistema operacional. Onde o sistema não oferece cifragem, o arquivo é gravado em claro e o próprio registro diz qual dos dois casos aconteceu.
O servidor
As três formas de rodar
O mesmo código de servidor sobe de três maneiras, e a diferença não é técnica — é de quem cuida dele:
Hospedar pelo app é o caminho de dois cliques, e é por isso que o cliente importa @monky/server diretamente: não há processo separado nem porta de administração. O preço é que o servidor vive enquanto o app viver. O app pode hospedar vários servidores ao mesmo tempo no mesmo processo, cada um com sua porta e sua pasta de dados; os logs de cada um são separados por instância.
O CLI existe para o caso oposto — um servidor que não deveria depender de alguém manter uma janela aberta. Ele administra vários servidores na mesma máquina, cada um com sua pasta de dados e seu processo no PM2.
Camadas
Não há container de injeção de dependência: a montagem é explícita, feita à mão em MonkyServer.create(). Você consegue ler o arquivo e ver exatamente o que depende do quê.
Além do WebSocket, o servidor expõe algumas rotas HTTP: /health, /preview, /invite-info (endereços para a tela de convite, respondida só no próprio computador do servidor; os membros recebem o mesmo pelo WebSocket), /avatars/* e o upload/download de /attachments.
A porta padrão é a 3000.
O protocolo
Toda mensagem tem o mesmo formato:
{
type: MessageType, // 'CHAT_SEND', 'VOICE_JOIN', 'RTC_SIGNAL'…
requestId?: string, // volta na resposta, para correlacionar
payload: T
}O requestId é o que permite ao cliente saber qual resposta pertence a qual pedido — o WebSocket é assíncrono e as respostas não chegam necessariamente na ordem em que foram feitas.
A validação dos payloads usa zod, com os schemas em packages/shared — os mesmos que o cliente usa para validar antes de enviar.
Compatibilidade negociada, nunca um downgrade irrestrito
Desde o protocolo 26, os pisos de clientes e de bots são independentes. Hoje são 35 para clientes (MIN_CLIENT_PROTOCOL) e 24 para bots (MIN_BOT_PROTOCOL), além de recursos em AUTH_CONNECT/AUTH_SUCCESS. O contrato nativo de tela 4K/80 Mbps exige atualizar cliente e servidor; clientes mais antigos não são rebaixados silenciosamente. Bots 24 continuam usando seu contrato anterior de áudio e comandos. Blocos de mensagem e configuração de limite só são habilitados com as capacidades correspondentes. O SDK pode repetir a autenticação uma vez no contrato conhecido de um servidor antigo, respeitando o piso de bots. Versões abaixo do piso são recusadas; versões futuras precisam anunciar explicitamente um intervalo compatível.
Mudanças aditivas não exigem elevar o piso. Uma correção crítica ou alteração incompatível deve elevar MIN_CLIENT_PROTOCOL e/ou MIN_BOT_PROTOCOL em protocolCompatibility.ts, atualizar o manifesto de release e testar as rejeições antes da publicação. A política de versionamento major do CI para alterações de PROTOCOL_VERSION permanece vigente.
O protocolo 37 adiciona BOT_DIAGNOSTIC, a única mensagem de bot aceita antes da autenticação. O doctor do CLI de execução a envia por uma conexão própria: o servidor confere o token sem vincular a chave (TOFU) nem substituir a sessão do bot em execução, informa a compatibilidade de protocolo e testa de fora as portas públicas do bot. Uma porta só conta como acessível quando assina um desafio novo com a chave Ed25519 do bot; endereços privados (exceto o do próprio solicitante), portas de sistema, redirecionamentos e diferenças de tempo não revelam nada sobre hosts de terceiros. Servidores anteriores respondem como mensagem malformada. Veja Portas, configurações e verificação.
Autenticação por identidade
O login é por desafio-resposta com criptografia de chave pública. Você não tem conta nem cadastro: sua identidade é o seu par de chaves.
O clientId é derivado da própria chave pública, então ele não pode ser falsificado: quem não tem a chave privada não consegue assinar o desafio.
A senha do servidor, quando existe, protege a entrada e é conferida pelo servidor antes do desafio. Ela é diferente da senha local do backup da sua identidade. A chave privada e a senha desse backup não são enviadas no login.
A assinatura vale para um servidor só
Se a assinatura cobrisse só o nonce, um servidor M em que você entrou poderia abrir uma conexão com outro servidor A usando a sua chave pública, repassar a você o desafio de A e entregar a sua resposta a A, entrando lá como você. Por isso cada servidor tem uma chave de identidade Ed25519 própria e a assinatura passa a nomear essa chave (recurso negociado server-key-auth):
- No
AUTH_CHALLENGE, o servidor manda o nonce, a sua chave pública e uma assinatura demonky-server-auth:v1:<chave do servidor>:<nonce>, que prova que ele tem a chave que apresenta. - O app confere essa prova e assina
monky-auth:v2:<chave do servidor>:<nonce>, nunca o nonce cru. Uma resposta obtida por M fica vinculada à chave de M e não vale em A. - No primeiro login protegido, o app fixa a chave para aquele endereço salvo (TOFU) e a guarda no próprio servidor salvo, que vai junto no backup de servidores. Um convite também pode trazer a chave; ele continua abrindo em versões anteriores do app, que só não a fixam.
O app decide (decideServerKey, em @monky/shared) antes de assinar qualquer coisa:
| Situação no endereço | O que o app faz |
|---|---|
| Nunca apresentou chave | Assina o nonce, como antes (servidores antigos continuam funcionando) |
| Primeira chave, de nenhum outro servidor salvo | Assina vinculado a ela e a fixa ao entrar |
| A mesma chave fixada | Assina vinculado a ela |
| Outra chave | Recusa (Chave mudou). Só um convite novo que traga a chave nova a substitui |
| Nenhuma chave, depois de já ter apresentado uma | Recusa: não há rebaixamento para o formato antigo |
| A chave de outro servidor salvo | Recusa, mesmo vindo de convite. Um segundo endereço do mesmo servidor entra pelo menu do servidor salvo, em Adicionar endereço, que copia a chave fixada |
O servidor também não aceita rebaixamento: depois do primeiro login vinculado de um membro, ele recusa as assinaturas do nonce cru daquela identidade (FEATURE_REQUIRES_UPDATE), porque um servidor intermediário poderia ainda obtê-las de um app que nunca o viu com chave. Bots não usam esse desafio (veja bot-key-proof).
A chave privada do servidor mora no server.db (tabela server_identity), criada na primeira subida que não a encontra e gravada em disco antes de o servidor aceitar conexões. Por isso ela acompanha a cópia do banco feita antes de cada atualização, a pasta de dados levada para outra máquina e qualquer reinício. Trocar a chave (apagar a tabela, ou começar com um banco novo no mesmo endereço) faz os apps recusarem o servidor até receberem um convite novo.
O Monky Light segue a mesma regra e guarda as chaves fixadas no perfil, em monky-light-servers.json, por endereço de servidor. Ele aceita um link de convite onde aceita o endereço, e é assim que recebe uma chave nova; não tem Adicionar endereço.
Risco residual. A proteção vale a partir do primeiro login protegido em A. Se você nunca entrou em A com um app que tenha o recurso, um servidor M visitado pela primeira vez pode apresentar a chave de A e repassar o desafio dele; o app não tem como distinguir isso de um servidor novo legítimo. Depois de você entrar em A uma vez, a chave de A fica fixada e qualquer outro endereço que a apresente é recusado.
Sessões: a mesma pessoa em vários aparelhos
Uma sessão é identificada por userId:deviceId, não só pelo usuário. É isso que permite você estar no desktop e no notebook ao mesmo tempo, aparecendo como uma pessoa só.
- Reconectar do mesmo aparelho substitui a conexão antiga (evita fantasmas depois de uma queda de rede).
- Conectar de outro aparelho cria uma sessão nova, até o limite de 3 sessões simultâneas por identidade.
O limite existe para uma identidade só não esgotar os recursos do servidor (conexões, áudio, banda) abrindo aparelhos sem fim. Ele é independente do maxUsers, que conta cadastros — várias sessões da mesma pessoa continuam valendo uma vaga só.
Banco de dados
SQLite, em um arquivo server.db dentro da pasta de dados do servidor. As migrações são arquivos .sql numerados, aplicados na subida e registrados numa tabela schema_migrations — o servidor só roda o que ainda não rodou.
| Tabela | Guarda |
|---|---|
server_meta | Configuração do servidor: nome, senha, dono, limites, ícone |
server_identity | A chave de identidade Ed25519 do servidor (vínculo da assinatura) |
users | Membros, com a chave pública de cada um |
channels | Canais de voz e de texto |
messages | Histórico do chat |
mentions | Menções, para destacar e notificar |
message_attachments | Anexos das mensagens |
roles / user_roles | Cargos e quem tem cada um |
schema_migrations | Controle das migrações já aplicadas |
Cargos e permissões
Permissões são bits combinados numa máscara. Dois cargos são especiais:
- Admin — recebe todas as permissões e não pode ser apagado.
- Membro — o cargo padrão de quem entra.
A checagem é centralizada em PermissionService.checkPermission(). O dono do servidor é um caso à parte: ele recebe as permissões de admin independentemente dos cargos que tenha.
Proteção contra abuso
| Proteção | Como |
|---|---|
| Flood de mensagens | Janela deslizante: 10 mensagens a cada 5 s |
| Tamanho da mensagem | 16.000 caracteres por padrão; configurável por servidor, 0 sem limite de caracteres; pacote WebSocket até 8 MiB |
| Avatar | 5 MB, e o arquivo precisa ter assinatura de PNG, JPEG ou WebP |
| Anexos | Limite por arquivo e orçamento total do servidor, ambos configuráveis |
| Soundboard | Áudio recusado acima de ~4 MB |
| Path traversal | Nomes de arquivo passam por basename e o caminho final é conferido contra a pasta permitida |
Repare no detalhe do avatar: a validação olha os bytes mágicos do arquivo, e não a extensão. Renomear um executável para .png não engana a checagem.
O plano de mídia
O Monky suporta dois modos de voz e mídia: o modo padrão P2P Mesh e o modo SFU (Selective Forwarding Unit) baseado em mediasoup.
Topologia 1: P2P Mesh (Padrão)
Cada participante abre uma conexão direta com cada um dos outros.
Com N participantes, cada pessoa mantém N−1 conexões e o canal tem N(N−1)/2 no total. Quem compartilha tela envia o mesmo vídeo N−1 vezes, uma para cada par.
Quando usar P2P Mesh
Excelente para grupos pequenos de amigos e servidores em VPSs econômicos (como instâncias gratuitas com pouca CPU/banda), pois o servidor não carrega pacotes de áudio/vídeo.
Topologia 2: SFU (Selective Forwarding Unit)
Na chamada SFU do motor de navegador, o cliente abre um par de WebRTC Transports com o servidor:
sendTransport: Envia as trilhas locais (microfone e webcam).recvTransport: Recebe as trilhas de todos os outros participantes roteadas pelo workermediasoup.
O servidor identifica esse par pelo propósito call, também usado quando purpose é omitido. Motores de tela com conexões próprias podem usar um par screen, restrito a vídeo e áudio de tela. Producers e consumers registram seu transportId: reconstruir um propósito não descarta os recursos do outro. Criações em andamento também têm esse escopo: respostas tardias do worker são fechadas após cancelamento, saída ou troca de canal, sem registrar transportes abandonados nem afetar uma conexão substituta. A saúde da conexão de voz considera o par call; a saúde de screen é consultada separadamente. Isso prepara o isolamento do motor nativo sem mudar o par atualmente usado pelo renderer.
O encerramento explícito SFU_CLOSE_WEBRTC_TRANSPORT seleciona um ID de transporte screen, não o par inteiro, e responde com SFU_WEBRTC_TRANSPORT_CLOSED. SFU_PRODUCER_SET_PAUSED também se restringe à mídia de tela da própria sessão. Pausa/retomada de producers e consumers aguarda a resposta do worker e confere se o recurso ainda existe. Pedidos com requestId recebem confirmação ou erro correlacionado, inclusive no encerramento de producers/consumers; os controles antigos sem requestId continuam sem uma confirmação extra.
Quem transmite envia cada fluxo uma única vez ao servidor, em vez de uma cópia por destinatário. Essa redução de envios não garante, sozinha, resolução, FPS ou um custo específico de CPU/GPU.
Resiliência & Reconexão Automática
Se o processo SFU sofrer qualquer falha ou indisponibilidade, o cliente avisa em tela e passa a refazer a sessão SFU automaticamente, com espera progressiva entre as tentativas, até o servidor voltar.
Não existe queda para P2P: uma malha em que só o lado que percebeu a falha troca de protocolo nunca se forma, porque o outro lado continua respondendo como cliente SFU e descarta a oferta recebida. O resultado seria uma chamada muda por trás de um aviso tranquilizador — por isso o caminho é reconectar, e não degradar.
Quando um administrador muda explicitamente de SFU para P2P, os participantes são avisados e o cliente encerra o transporte anterior antes de voltar automaticamente ao próprio canal. Essa entrada usa uma autorização temporária, de uso único, vinculada à sessão e ao canal, e respeita as permissões e os limites atuais. Sair voluntariamente, ser removido ou iniciar outra chamada cancela o retorno automático.
Sinalização
O servidor só entrega envelopes. Ele reescreve o remetente (para ninguém forjar identidade) e recusa a entrega se os dois pares não estiverem no mesmo canal de voz.
Atravessando o NAT
Quase ninguém tem IP público direto, então os pares precisam descobrir por onde se alcançam. O Monky usa servidores STUN públicos (Google e Cloudflare) para cada lado descobrir seu próprio endereço externo.
TURN é opcional, e desligado por padrão
STUN só descobre o caminho; ele não repassa nada. Quando a rede é restritiva demais — NAT simétrico, firewall corporativo, alguns CGNATs de operadora — não existe caminho direto e a conexão de mídia falha.
Um servidor TURN resolve isso retransmitindo a mídia. Mas TURN carrega vídeo, e custa banda proporcional ao uso — o que reintroduz exatamente o custo que a arquitetura P2P evita. Por isso o relay do Monky é opcional e vem desligado: quem hospeda decide se quer pagar essa banda.
Quando ligado, o servidor sobe um coturn ao seu lado e entrega as credenciais no login. O ICE continua preferindo a rota direta e só usa o relay para os pares que realmente não conseguem se conectar. Detalhes em Relay de mídia (TURN).
Quando uma conexão cai ou trava, o WebRtcManager primeiro tenta um ICE restart (renegociar o caminho sem derrubar a chamada) e, se não resolver, refaz a conexão do zero com aquele par.
Áudio
A captura já pede cancelamento de eco e ganho automático ao WebRTC nativo. O diagrama ilustra o motor padrão, RNNoise; Speex e GTCRN usam a mesma posição na cadeia. Os três rodam em AudioWorklet, com WASM empacotado pela dependência @sapphi-red/web-noise-suppressor. Ao selecionar um deles, a supressão nativa é desligada para evitar processamento duplicado. Também é possível usar somente a supressão do WebRTC (nativo) ou nenhuma.
AudioProcessor prepara o novo motor antes de substituir a conexão interna, mantendo a track de destino enviada por P2P ou SFU. A prévia local compartilha o fluxo processado da chamada quando possível; quando precisa capturar por conta própria, aplica o mesmo motor sem se conectar à saída nem ao WebRTC.
As saídas resolvem preferências gerais e por categoria. Voz e áudio de tela têm AudioContexts separados em toda a faixa de volume, de 0 a 200%. O Chromium compartilha um renderizador nativo entre as tracks WebRTC: os elementos de áudio ficam ativos com volume zero para decodificação, e somente os grafos por categoria produzem som. A saída nativa compartilhada acompanha a voz para manter coerente a referência de cancelamento de eco; ela nunca é redirecionada para a saída escolhida para a tela. Os players nativos de chat, incluindo o visualizador ampliado, aplicam a saída de mídias antes de reproduzir.
Mudo e ensurdecer desabilitam a track (enabled = false) em vez de removê-la. Assim não é preciso renegociar a conexão a cada clique no botão de mudo.
Vídeo e compartilhamento de tela
A câmera segue as dimensões e o FPS do perfil de qualidade escolhido.
O compartilhamento de tela é separado da câmera — você pode transmitir as duas ao mesmo tempo. A fonte é anunciada na chamada (nativeScreenShares) sem exigir o recebimento de sua mídia, e cada assinatura usa conexões próprias, fora da conexão de voz.
Cada pessoa pode compartilhar até 8 telas ao mesmo tempo. O limite é por pessoa, não por chamada: cada tela custa a quem transmite outra captura, outro encoder e mais upload, enquanto quem assiste só recebe as telas que escolheu assistir. Por isso, a partir da terceira tela o Monky pede confirmação uma vez por chamada. Cada uma é identificada pelo id da fonte (shareId), que amarra o anúncio, as assinaturas e o quadradinho na tela.
O recurso screen-shares-8 é negociado. Servidores sem ele aceitam 2 telas por pessoa, então o cliente não oferece uma terceira nesse servidor. Clientes sem ele recebem só as 2 primeiras telas de cada pessoa, que é o que o roster nativo deles aceita; o servidor também recalcula se a pessoa compartilha som para esse cliente.
Descoberta não é assinatura. A intenção de assistir pertence à chamada, não ao ciclo de vida de uma view. Assistir transmissão autoriza a entrega daquela fonte ao destinatário; Parar de assistir a revoga. A escolha acompanha a mesma fonte em reconstruções de view e transporte, mas não é herdada por uma nova transmissão apenas por ter o mesmo dono.
NATIVE_SCREEN_SIGNAL leva a intenção ao emissor: watch e stop partem de quem assiste; accepted, capture-mode e closed, do emissor. Cada assinatura tem o próprio subscriptionId e cita a instância da fonte (sourceInstanceId); parar afeta somente aquela assinatura. O servidor autentica o remetente e verifica canal e fonte publicada.
Em SFU, o catálogo continua disponível sem criar consumers de tela automaticamente. Os consumers de tela/áudio começam pausados no servidor e só são liberados depois que o setup ainda válido termina. Parar fecha o consumer remoto e local; pausar somente a track no cliente não seria suficiente para cortar banda. Respostas tardias não podem reativar uma assinatura cancelada.
O áudio da tela é compartilhado por publisher/destinatário: ele continua enquanto esse destinatário assistir pelo menos uma tela daquele publisher. Microfone, câmera, outros espectadores e preferências de mute são independentes. A última assinatura de um perfil encerra sua captura, encoder e envio, inclusive publisher→SFU. RTCP e sinalização continuam permitidos; não se promete zero bytes na chamada. Cliente e servidor negociam esses contratos ao conectar, como descrito em "Compatibilidade negociada", acima: a lista de espectadores, por exemplo, só aparece quando os dois lados anunciaram screen-viewers.
Áudio da tela: o módulo nativo
O navegador não entrega o som do sistema junto com a imagem da tela. Por isso o Monky tem um módulo nativo em C++:
| Plataforma | Como |
|---|---|
| Windows | WASAPI process loopback — captura o som do sistema ou de um app específico, excluindo o próprio Monky para não gerar eco |
| macOS | ScreenCaptureKit (macOS 13+ para áudio; caminho de vídeo nativo requer 14+), com áudio do sistema ou do aplicativo inteiro selecionado, mediante confirmação |
| Outras | Não suportado — o app segue funcionando, só sem áudio de tela |
| Versão web | O som que o navegador entrega com a captura: no Chrome e no Edge, o de uma guia e, em alguns sistemas, o da tela inteira |
No Windows, o mesmo módulo também identifica as janelas e os monitores do seletor, lê o layout do teclado para os atalhos e limita o redimensionamento do overlay. Por isso ele embute o runtime do Visual C++ em vez de depender do redistribuível instalado no sistema, e o empacotamento recusa um build que volte a exigi-lo. Se mesmo assim o módulo não carregar, o processo principal registra a causa no log do app, e as funções que dependem dele falham com essa causa, em vez de devolverem listas vazias: no Windows, o seletor não consegue listar janelas nem monitores. No macOS, o compartilhamento continua funcionando sem som e o app avisa.
Cada compartilhamento com som abre a própria sessão de captura. O módulo aceita até 8 sessões ao mesmo tempo, uma para cada tela permitida por pessoa. Cada sessão roda numa thread própria, que avisa o loop do Node ao terminar; nenhuma ocupa o pool de threads do libuv, que atende arquivos, DNS e criptografia. Quais sons podem coexistir é decidido no processo principal, que repete as checagens do seletor: o som de um monitor (e, no macOS, o de qualquer fonte, porque o ScreenCaptureKit entrega a mistura do sistema) não convive com outro, e duas janelas do mesmo processo não capturam o mesmo som. Como o process loopback inclui os processos que o aplicativo abriu, o mesmo vale para processos em que um descende do outro, como um launcher e o jogo que ele abriu; o seletor recebe a cadeia de processos pais de cada janela e o processo principal confere de novo, num instantâneo atual, antes de capturar. Quando uma tela assume o som de aplicativos, cada um deles troca para uma nova instância da mesma transmissão, sem áudio — quem assiste acompanha a troca como numa mudança de qualidade —, e a captura da tela só começa depois que eles liberam o som.
Qualidade e banda
Os perfis controlam resolução, FPS e teto de bitrate. Microfone e câmera aplicam esses tetos com RTCRtpSender.setParameters(); na tela, eles configuram o pipeline nativo real de captura/encoder/transporte:
| Perfil | Áudio | Câmera | Tela |
|---|---|---|---|
| Leve | 24 kbps | 640×360 @ 30fps · 300 kbps | 640×360 @ 30fps · 700 kbps |
| Padrão | 32 kbps | 854×480 @ 30fps · 500 kbps | 1280×720 @ 30fps · 2500 kbps |
| Nítido | 48 kbps | 1280×720 @ 30fps · 1000 kbps | 1920×1080 @ 30fps · 4500 kbps |
| Fluido | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 1920×1080 @ 60fps · 8000 kbps |
| Ultra | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 2560×1440 @ 60fps · 12000 kbps |
| Cinema | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 3840×2160 @ 60fps · 25000 kbps |
| Extremo | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 3840×2160 @ 120fps · 40000 kbps |
Cada degrau melhora a tela sem piorar câmera ou áudio, e nenhum fica abaixo de 30fps. Os tetos ficam acima do usual em streaming porque os encoders de captura emitem um quadro-chave por segundo. 4K a 120fps exige H.264 nível 6.0 ou AV1; ao escolher o perfil, o Monky verifica o encoder e reduz o FPS se ele recusar.
Nos perfis nomeados a partir de 60fps (Fluido até Extremo), a preferência de degradação da câmera vira maintain-framerate: sob banda apertada o Monky sacrifica resolução para segurar o FPS; nos outros perfis é o contrário. A tela nativa solicita o perfil real escolhido e não esconde degradação como se a configuração garantisse FPS.
O perfil Personalizado aceita até 3840×2160 e 240 FPS (120 FPS em 4K). O espectador pode pedir Fonte, 1080p60, 720p60 ou 480p30 (852×480); perfis diferentes alocam pipelines separados, e não um redimensionamento só no player.
Lembre que esses números são por par. Compartilhar tela no perfil Nítido para 4 pessoas pede ~18 Mbps de upload.
Telemetria
Durante uma transmissão, a telemetria distingue configuração, envio, decodificação e apresentação. Estatísticas RTP usam deltas do timestamp do próprio relatório; contadores Media Foundation usam o relógio de observação do worker, não o horário em que o JavaScript leu um snapshot em cache. Métricas indisponíveis permanecem indisponíveis, não viram zero. O transporte H.264 externo não finge medir o tempo de codificação AMF como se fosse um encoder interno do WebRTC.
Reconexão
Quando o WebSocket cai, o cliente tenta voltar sozinho, com esperas crescentes (1s, 2s, 3s, 5s — a última se repete). O servidor mantém a sessão viva por 20 segundos antes de anunciar a saída, então uma queda rápida de Wi-Fi não expulsa ninguém da lista.
Repare no passo mais contraintuitivo: ao voltar, o cliente derruba todas as conexões P2P e recomeça, mesmo as que pareciam vivas. Parece drástico, mas é o caminho mais confiável — enquanto o WebSocket esteve fora, outras pessoas podem ter entrado, saído ou trocado de canal, e não há como saber quais dos pares antigos ainda valem. Reconstruir a partir do estado novo é mais barato que descobrir, par a par, quem sobrou.
Se os 20 segundos estourarem antes da volta, a sessão é encerrada e a saída é anunciada normalmente — a reconexão vira uma entrada nova.
Onde cada coisa mora
apps/
client/
native/screen-audio/ módulo C++ de áudio de tela (Windows/macOS)
native/screen-share/ captura libobs, RTC nativo, contratos, build e licenças
src/main/ processo main: janela, bandeja, updater, IPC
src/preload/ a ponte window.api
src/renderer/
core/ serviços: rede, WebRTC, áudio, vídeo
platform/ contrato da ponte (PlatformApi) e a implementação web
stores/ estado + eventos
views/ as telas
i18n/ traduções PT/EN
server/
src/application/ regras de negócio (services)
src/infrastructure/ WebSocket, banco, segurança, logs
src/cli/ o Monky CLI
packages/
shared/ protocolo, validadores, limites, perfis
dm/ amigos e mensagens diretas (desktop e web)Limites conhecidos
Coisas que são consequência direta da arquitetura, não bugs:
- Mesh não escala. Ótimo para um punhado de amigos, ruim para dezenas. Quem precisa de grupos maiores troca para o modo SFU, pagando com banda e CPU do host o que economiza na conexão de cada participante.
- TURN desligado por padrão. Redes muito restritivas podem impedir a conexão de mídia mesmo com o servidor acessível. Há relay opcional, mas ele custa banda do host e só roda em Linux.
- Compatibilidade tem piso. Cliente e servidor negociam versão e recursos ao conectar, mas só a partir de um piso (
MIN_CLIENT_PROTOCOLpara clientes,MIN_BOT_PROTOCOLpara bots). Quem estiver abaixo dele não entra até atualizar, e um recurso novo só aparece quando os dois lados o têm. - Versão web só em servidores seguros. Uma página https só abre conexões
wss://(ou com o próprio computador). Servidores acessados por IP, VPN ou rede local continuam só no app de desktop. - Áudio de tela só em Windows e macOS, por depender de API nativa de cada sistema. Na versão web, quem decide é o navegador: Chrome e Edge compartilham o som de uma guia e, em alguns sistemas, o da tela inteira.
- O primeiro contato confia no servidor. A chave de um servidor é fixada no primeiro login protegido. Antes disso, um servidor desconhecido pode apresentar a chave de outro que você ainda não visitou com o app atual (veja risco residual).
- Homologação depende do hardware. O caminho macOS foi exercitado em M1 com áudio/vídeo P2P/SFU e cerca de 57 FPS em 4K60. O probe aceita Main 6.0/4K120, mas isso não homologa 120 FPS sustentados nem GPU Intel. Loopback e compilação x64 via Rosetta não substituem QA em rede externa ou hardware Intel.
Para saber mais
- Monky CLI — administração por linha de comando
- Hospedar em VPS — colocar um servidor no ar
- Recursos — o que o app faz, do ponto de vista de quem usa
- CONTRIBUTING.md — como contribuir com código
