← Voltar ao blog

Por que reescrevemos nosso app Electron em Tauri/Rust

engineering tauri rust

Por que deixamos o Electron para trás

O GitSquid v1 foi construído sobre Electron. Funcionava. Os utilizadores podiam navegar em repositórios, preparar alterações, gerir branches, lidar com remotes e usar um terminal integrado -- tudo a partir de uma única aplicação desktop. Por baixo, dependíamos do simple-git para operações Git, node-pty e xterm.js para o terminal, e a stack React habitual para a interface.

Mas à medida que a aplicação amadurecia, os compromissos do Electron tornavam-se mais difíceis de ignorar. Tínhamos lançado um produto funcional, mas o próprio runtime trabalhava contra nós. Então reescrevemos todo o backend em Rust usando Tauri 2.x -- mantendo 95% do nosso frontend React intacto.

Esta é a história de porque o fizemos, como o fizemos e o que se partiu pelo caminho.

O problema com o Electron

O Electron é um framework comprovado. VS Code, Slack, Discord -- grandes produtos funcionam nele. Mas para um cliente Git, o overhead é desproporcional em relação à tarefa.

  • Tamanho da app: O Electron inclui um browser Chromium completo e um runtime Node.js. A nossa aplicação empacotada pesava mais de 150 MB. Para um cliente Git, isso é difícil de justificar.
  • Uso de memória: Cada janela Electron gera múltiplos processos Chromium. O consumo de memória em repouso ficava bem acima do que uma app nativa necessitaria para o mesmo trabalho.
  • Tempo de arranque: Lançar o Chromium não é instantâneo. Os utilizadores notavam um atraso percetível antes de a aplicação se tornar interativa, especialmente em hardware mais antigo.
  • Sensação nativa: Apesar dos esforços do Electron com barras de título e menus nativos, a aplicação nunca se sentia verdadeiramente como parte do sistema operativo. Pequenos detalhes -- gestão de janelas, comportamento do foco, atalhos de teclado -- tinham sempre um ligeiro sabor a aplicação web.

Nenhum destes é um fator decisivo por si só. Mas combinados, criavam uma fricção que não se alinhava com o que uma ferramenta de desenvolvimento deveria transmitir: rapidez, leveza e invisibilidade.

Porquê Tauri 2.x

Avaliámos várias alternativas antes de nos decidirmos pelo Tauri. Os fatores decisivos foram claros:

  • Webview do sistema em vez de Chromium incluído. O Tauri usa a webview fornecida pelo sistema operativo (WebKit no macOS, WebView2 no Windows, WebKitGTK no Linux). Só isto elimina a maior parte do tamanho binário do Electron.
  • Backend em Rust. O backend corre como um binário Rust compilado. Segurança de memória, sem garbage collector, desempenho previsível. Para uma ferramenta que executa dezenas de operações Git por interação, isto importa.
  • Binários drasticamente mais pequenos. Uma app Tauri pode ser distribuída com menos de 10 MB. Compare com 150 MB+.
  • Multiplataforma com integração nativa. O Tauri 2.x fornece APIs sólidas para menus nativos, system tray, notificações e diálogos de ficheiros. A aplicação sente-se como parte do sistema operativo.
  • Ecossistema ativo e boa experiência de desenvolvimento. O sistema de plugins do Tauri, o modelo IPC e a documentação amadureceram significativamente com o lançamento 2.x. Estava pronto para uso em produção.

A estratégia de migração

Uma reescrita completa é arriscada. Mitigámos esse risco com uma clara separação de responsabilidades.

Manter o frontend, reescrever o backend

O nosso frontend React já estava estruturado em torno de um limite IPC. Os componentes chamavam funções que comunicavam com o processo principal do Electron; apenas precisávamos redirecionar essas chamadas para a API invoke do Tauri. Cerca de 95% do código React foi transferido sem alterações.

O backend foi outra história. Cada serviço Node.js -- operações Git, monitorização de ficheiros, gestão do terminal, autenticação, definições -- teve de ser reimplementado em Rust. No total, escrevemos aproximadamente 7.200 linhas de Rust e migrámos mais de 120 comandos IPC.

Git CLI em vez de libgit2

Uma pergunta frequente: porquê não usar git2-rs (os bindings Rust para libgit2)?

Escolhemos std::process::Command para chamar diretamente a Git CLI. As razões:

  • libgit2 não suporta todas as funcionalidades do Git. Operações como rebase interativo, algumas estratégias de merge e certas opções de configuração estão ausentes ou comportam-se de forma diferente.
  • Os utilizadores já têm o Git instalado. Ao chamar o binário Git local, garantimos paridade de comportamento com a linha de comandos. Se funciona no terminal, funciona no GitSquid.
  • A depuração é mais simples. Os comandos que executamos são os mesmos que um utilizador digitaria. Quando algo corre mal, as mensagens de erro são familiares.

Substituir o ecossistema Node.js

Cada dependência Node.js foi substituída por um equivalente em Rust:

  • simple-git tornou-se em chamadas diretas à Git CLI via std::process::Command
  • node-pty tornou-se em portable-pty para emulação de terminal
  • O armazenamento de tokens passou para o crate keyring, que usa o porta-chaves do sistema operativo (macOS Keychain, Windows Credential Manager, Linux Secret Service)
  • A monitorização de ficheiros passou para o crate notify
  • Os pedidos HTTP passaram para reqwest

Migração de definições sem interrupção

Mantivemos a mesma estrutura de diretório de configuração. Os utilizadores que atualizam da v1 para a v2 não perdem as suas definições, repositórios guardados ou preferências. O backend Rust lê os mesmos ficheiros de configuração que o backend Node.js escreveu.

Desafios que enfrentámos

A migração não foi um mar de rosas. Vários problemas custaram-nos tempo significativo de depuração.

Bytes nulos no parsing de Git log

Usamos strings de formato personalizadas com git log --format para fazer o parsing dos dados de commits. Na versão Electron, usávamos caracteres separadores específicos entre campos. Ao portar para Rust, inicialmente tentámos usar bytes nulos (\x00) como separadores de campo -- pareciam uma escolha segura já que nunca apareceriam em mensagens de commit.

Errado. As strings em Rust lidam com bytes nulos sem problema, mas várias camadas do stack tratam-nos como terminadores de string ao estilo C. O output do log era silenciosamente truncado no primeiro byte nulo. Mudámos para um delimitador multi-caractere que nunca ocorreria naturalmente no output do Git.

Armazenamento de tokens no porta-chaves

O crate keyring armazena credenciais no porta-chaves do sistema operativo, que é a abordagem correta. Mas deparámo-nos com um problema similar de bytes nulos: estávamos a concatenar dados de tokens com separadores de bytes nulos antes de os armazenar. Na recuperação, algumas implementações do porta-chaves do sistema operativo truncavam o valor no primeiro byte nulo, devolvendo credenciais incompletas. A solução foi a mesma -- usar um separador visível e inequívoco.

Complexidade da autenticação BitBucket

O GitHub e o GitLab têm fluxos de autenticação baseados em tokens relativamente simples. O BitBucket é mais complexo. Dependendo da operação e do tipo de conta, pode ser necessário um nome de utilizador, um email, uma password de aplicação ou um token OAuth -- e as regras de qual usar e onde nem sempre são óbvias. Conseguir que a autenticação BitBucket funcionasse de forma fiável para clone, fetch, push e chamadas à API exigiu várias iterações.

Staging por linha

O GitSquid suporta o staging de linhas individuais de um diff, não apenas hunks completos. Na versão Electron, a geração de patches acontecia em JavaScript. Para a versão Tauri, movemos esta lógica para o backend Rust. Gerar formato de patch Git válido a partir de seleções arbitrárias de linhas, lidar com casos limite em linhas de contexto e garantir que o git apply aceita o resultado -- esta foi uma das partes mais intrincadas da reescrita. A implementação em Rust acabou por ser mais robusta do que a versão original em JavaScript.

Apóstrofos franceses a partir builds

Este caso foi memorável. Algumas das nossas strings visíveis para o utilizador continham apóstrofos tipográficos franceses (a variedade curva, U+2019) em vez de apóstrofos ASCII retos. Estes causaram problemas de encoding durante o processo de build em certas configurações de CI. A solução foi trivial uma vez identificada, mas as mensagens de erro não apontavam minimamente para o problema real.

Resultados

Após completar a migração e atingir paridade total de funcionalidades, eis os nossos resultados:

  • Tamanho da app: significativamente reduzido comparado com o build Electron. O pacote Tauri é uma fração dos 150 MB+ que distribuíamos antes.
  • Uso de memória: baseline mais baixa e mais previsível sob carga. Sem árvore de processos Chromium a consumir RAM em segundo plano.
  • Tempo de arranque: notavelmente mais rápido. A aplicação fica interativa quase imediatamente.
  • Paridade de funcionalidades: 100%. Cada funcionalidade da v1 existe na v2. O terminal integrado, staging por linha, suporte multi-remote, gestão de branches -- tudo.
  • Reutilização do frontend: 95% do código React foi transferido, validando a nossa decisão arquitetural de manter um limite IPC limpo.

Valeu a pena?

Sim.

A reescrita exigiu um esforço significativo. Aproximadamente 7.200 linhas de Rust, mais de 120 comandos IPC para reimplementar e vários bugs não óbvios que só surgiram porque Rust e Node.js lidam com casos limite de forma diferente. Não foi um projeto de fim de semana.

Mas o resultado é uma aplicação fundamentalmente melhor. O GitSquid v2 arranca mais rápido, usa menos memória, é distribuído como um binário mais pequeno e sente-se mais nativo em cada plataforma. O backend Rust é mais previsível e mais fácil de raciocinar do que o equivalente Node.js. E como mantivemos o mesmo frontend React, os utilizadores veem a mesma interface que já conhecem.

Se está a manter uma aplicação Electron e o overhead do runtime o incomoda, o Tauri é uma opção séria. O ecossistema é maduro o suficiente para uso em produção, e o caminho de migração -- especialmente se já tem uma separação limpa entre frontend e backend -- é mais gerível do que uma reescrita completa poderia sugerir.

A perceção chave: trate o seu limite IPC como um contrato de API. Se o seu frontend apenas sabe como fazer invoke('get_commits', { path, branch }) e não se importa se a resposta vem de Node.js ou Rust, trocar o backend torna-se um projeto realista em vez de uma fantasia.