Pular para conteúdo

Decisões Técnicas

Registro consolidado das decisões de design e seus trade-offs. Formato leve de ADR (Architecture Decision Record). Cada decisão lista o contexto, a escolha e a justificativa/alternativas.


Frontend "burro", backend "inteligente"

Contexto: app Tauri tem duas linguagens; onde colocar a lógica?

Escolha: 100% da lógica de negócio no Rust. O React só dispara comandos e renderiza estado recebido por eventos.

Justificativa: evita estado duplicado entre JS e Rust; mantém credenciais e tokens fora do contexto JS (superfície de ataque menor); torna o frontend trivialmente substituível. Custo: todo dado de UI precisa cruzar a boundary explicitamente.


Escopo OAuth drive.file

Contexto: escopos do Drive vão de drive.file (só o que o app cria) a drive (tudo).

Escolha: drive.file + openid email.

Justificativa: é exatamente o que o Slot2Sync precisa (ele cria a pasta Slot2Sync/); é não-sensível, o que evita o processo de verificação restrita do Google (auditoria cara e lenta); reduz o risco para o usuário (o app não vê o resto do Drive dele). Alternativa drive rejeitada por excesso de permissão e fricção de publicação.


Proxy Worker esconde o client_secret

Contexto: o token endpoint do Google exige client_secret. Compilado no binário (option_env!), ele é extraível de uma release (strings/descompilador) e pode ser usado para abusar das credenciais do app conforme a base de usuários cresce.

Escolha: um Cloudflare Worker minúsculo intermedia /token e /refresh, guardando o client_secret como secret cifrado do Cloudflare. O app só conhece a URL pública do Worker e um PROXY_SECRET compartilhado (header X-Proxy-Secret). No CI, apenas CLIENT_ID, TOKEN_PROXY_URL e PROXY_SECRET são injetados — o client_secret nunca entra no GitHub.

Justificativa: o client_secret deixa de existir em qualquer artefato distribuído ou versionado. O redirect continua sendo o loopback http://127.0.0.1:<porta> tratado pelo app — o Worker não é redirect URI, então o cliente OAuth permanece do tipo Desktop app (único que aceita loopback em porta arbitrária).

Trade-off aceito: o PROXY_SECRET ainda é embutido no binário, logo extraível — barra abuso casual e permite rotação, mas não é segredo forte. A proteção real é o client_secret fora do binário. Suficiente para o porte do projeto; atestação de cliente fica fora de escopo.

Detalhes em Autenticação.


Evolução do client OAuth: de "Desktop app only" para client Web único (desktop + Android)

Contexto: o design original do proxy Worker (acima) assumia um único cliente OAuth tipo "Desktop app" (loopback 127.0.0.1). Ao adicionar suporte Android, três tentativas de tipo de client falharam: "Desktop app" rejeita custom URI schemes; "Web application" rejeita esses schemes na UI do Console; "Android" (Google Sign-In SDK) não aceita o fluxo PKEC baseado em browser usado pelo app (retorna invalid_request).

Escolha: um único client OAuth tipo Web application, com duas redirect URIs registradas — http://127.0.0.1 (desktop, qualquer porta) e https://<worker>/oauth/callback (Android). O Worker ganhou um endpoint novo, GET /oauth/callback, que recebe o code do Google e faz um redirect 302 para o deep link com.slot2sync.app:/oauth2redirect?...; o app mobile escuta esse deep link e troca o code no /token do Worker normalmente. SLOT2SYNC_GOOGLE_CLIENT_ID_ANDROID (client dedicado da tentativa anterior) foi removido — desktop e mobile passaram a compartilhar as mesmas variáveis de ambiente.

Justificativa: um client único simplifica a configuração (uma entrada no Google Console, não uma por plataforma) e reaproveita a infraestrutura do Worker já existente para esconder o client_secret — o endpoint /oauth/callback só faz um redirect, não expõe nenhum segredo.


OAuth2 com PKCE + redirect loopback

Contexto: app desktop nativo não tem como guardar um client secret de verdade.

Escolha: PKCE (RFC 7636) com redirect para 127.0.0.1:porta-efêmera (RFC 8252). O client secret exigido pelo Google para clientes Desktop vem de env, nunca do código, e a segurança real vem do PKCE.

Justificativa: padrão da indústria para apps instalados (rclone, gcloud SDK). O code_verifier nunca trafega na URL de autorização; só o challenge S256. state aleatório protege contra CSRF.


Token storage: keyring + memória

Contexto: onde guardar refresh e access tokens.

Escolha: refresh token no keychain nativo do SO (keyring); access token só em memória, renovado automaticamente com margem de 60s.

Justificativa: keychain é o local seguro do SO para segredos. Access token é efêmero e não precisa persistir. Tokens nunca cruzam a boundary — o frontend só vê AuthStatus. A trait de storage permite fallback futuro no Linux (Secret Service ausente em setups minimalistas).


Manifest: SQLite + snapshot JSON

Contexto: a spec pedia sync_manifest.json no Drive. JSON é frágil para estado operacional (concorrência, consultas, corrupção).

Escolha: a fonte de verdade operacional é a tabela SQLite local (sync_manifest); o sync_manifest.json no Drive é um snapshot exportado a cada sync.

Justificativa: SQLite é transacional, consultável e resistente a corrupção; serve à fila offline e ao diff. O snapshot JSON cumpre a estrutura especificada e serve para diagnóstico e bootstrap rápido de uma segunda máquina. Custo: duas representações, mas o JSON é derivado (write-only do ponto de vista do app).


Resolução de conflito por timestamp

Contexto: sync bidirecional precisa decidir quem vence quando um arquivo difere.

Escolha: o mais recente vence, com tolerância de ±2s e o par de mtimes do último sync registrado no manifest. Nunca deleta.

Justificativa: - A tolerância absorve granularidade de filesystem e pequenos desvios de relógio. - O par (local, drive) do último sync distingue "nada mudou" de "mudou de um lado" — essencial porque os relógios local e remoto divergem; sem isso, qualquer skew causaria re-sync eterno. - Uploads gravam o mtime local em modifiedTime; downloads aplicam o modifiedTime do Drive no arquivo local. Os dois lados convergem para o mesmo timestamp. - Nunca deleta: o pior caso é um save antigo sobrescrito no lado perdedor — e o histórico de revisões do Drive ainda permite resgate manual.

Alternativa (hash de conteúdo puro, sem mtime) rejeitada como critério primário por custo de I/O — hash entrou depois como pré-filtro complementar (ver decisão abaixo), não como substituto do timestamp.


Sem manifest prévio: primeiro sync, dispositivo desconhecido e conflito real

Contexto: além do caso "com manifest" acima, três cenários sem histórico de sync precisavam de regra própria — sobrepor um save de 100h por um de 20 minutos, uma edição simultânea silenciosamente resolvida por mtime sem aviso, e saves independentes de dispositivos diferentes tratados como se fossem o mesmo progresso.

Escolha: quando não há manifest e o arquivo existe nos dois lados, o Drive vence por padrão, mas com backup local automático antes de sobrescrever (DownloadWithBackup). Quando ambos os lados mudaram desde o último sync (com manifest), vira conflito explícito (SyncAction::Conflict): bloqueia o emulador, notifica e espera o usuário escolher via UI. O device_id estável refina ainda mais o caso "sem manifest": se a versão do Drive foi publicada por outro dispositivo, vira conflito em vez de Drive-vence-cego.

Justificativa: entre as alternativas avaliadas (sufixo de conflito preservando as duas versões no filesystem, notificação com decisão do usuário, backup automático, histórico de revisões do Drive, merge por formato de emulador), a combinação final prioriza não-destrutividade sem exigir UI nova nos casos mais comuns (backup automático resolve o primeiro sync) e decisão explícita só quando é genuinamente ambíguo (conflito real). Merge por formato foi descartado por quebrar o princípio de núcleo agnóstico ao emulador e por risco de corrupção de save. Detalhes em Sincronização e conflitos.


Fila offline como registro de intenção

Contexto: como retomar transferências que falharam por rede/arquivo em uso.

Escolha: a pendência registra que um arquivo precisa sincronizar, não como. O próximo sync re-detecta a diferença pelo diff (fonte da verdade) e refaz a operação; resolve limpa a pendência ao concluir.

Justificativa: imune a replay de operação obsoleta (ex.: enfileirou um upload, mas o arquivo mudou de novo antes do retry). Mais simples que uma fila de comandos com payload. A tabela tem dedupe (UNIQUE) e contagem de tentativas para diagnóstico.


Engine agnóstico a emuladores (SyncTarget)

Contexto: a arquitetura precisa suportar emuladores novos sem reescrever o sync.

Escolha: o engine opera sobre SyncTarget (rótulo + listas de caminhos). A conversão EmulatorProfile → SyncTarget é função de dados, fora do engine.

Justificativa: adicionar um emulador novo é editar dados (profiles.toml), não escrever código em sync/. Testável isoladamente (o diff e o conflito não tocam disco real além do scan). Ver Referência — Perfis de emulador.


Diff por hash SHA-256 como pré-filtro do mtime

Contexto: comparar só por mtime tem um falso positivo comum: um arquivo é tocado (ex.: o emulador reescreve o mesmo conteúdo, ou o sync anterior reancorou o mtime local) sem que o conteúdo mude — o que dispararia um upload/download desnecessário.

Escolha: quando o mtime local diverge do manifest mas o hash SHA-256 do conteúdo é igual ao registrado, o diff trata como "arquivo não mudou de verdade" — reancora o mtime no manifest sem transferir. O hash não substitui o timestamp como critério de decisão entre Upload/Download (isso continua sendo por mtime); ele só evita transferências desnecessárias quando o conteúdo é idêntico.

Justificativa: menos tráfego e menos chamadas à API do Drive em cenários onde o filesystem toca o mtime sem mudar bytes (comum em alguns emuladores). Calcular hash de todo arquivo a cada scan seria caro; o pré-filtro só entra quando o mtime já diverge.


Watcher de filesystem nativo, complementar ao watcher de processos

Contexto: o watcher de processos (abaixo) detecta abertura/fechamento do emulador, mas não mudanças de arquivo em si — útil para outros sinais (ex.: save recém-escrito enquanto o emulador já está rodando).

Escolha: um segundo watcher, baseado na crate notify (eventos nativos do SO), observa as pastas monitoradas em paralelo ao watcher de processos baseado em sysinfo.

Justificativa: os dois mecanismos respondem a perguntas diferentes ("o processo está rodando?" vs. "um arquivo mudou?") e são complementares, não substitutos um do outro.


Instância única do app via tauri_plugin_single_instance

Contexto: o Slot2Sync vive na bandeja; abrir o executável de novo enquanto já está rodando não deveria criar uma segunda instância (watchers e sync duplicados, conflito de lock do SQLite).

Escolha: plugin oficial tauri_plugin_single_instance, que detecta a instância já rodando e foca a janela existente em vez de subir um processo novo.

Justificativa: solução mantida pelo ecossistema Tauri, em vez de lock de arquivo manual — menos código próprio para uma garantia que é essencialmente do SO.


Versionamento e retenção de backups locais

Contexto: backups automáticos (primeiro sync, resolução de conflito) acumulam no disco indefinidamente se nada os limitar.

Escolha: cada arquivo sobrescrito é arquivado com carimbo de data/hora num histórico por emulador/categoria (list_file_versions/restore_version), com um número máximo de versões configurável por arquivo e uma retenção em dias configurável para o conjunto.

Justificativa: dá ao usuário uma forma de desfazer manualmente uma sobrescrita sem depender só do histórico de revisões do Drive, mantendo o crescimento de disco sob controle via limites explícitos em vez de acúmulo indefinido.


Limitação de banda e intervalo de scan configuráveis

Contexto: o Slot2Sync roda em segundo plano; usuários com conexões limitadas ou muitos arquivos monitorados podem querer conter o impacto do sync automático.

Escolha: limites de upload/download em KB/s (0 = ilimitado) e intervalo do scan periódico em minutos (0 = desativado), configuráveis pelo usuário e relidos a cada operação sem precisar reiniciar o app.

Justificativa: dá controle ao usuário sobre o custo de rede/CPU do sync automático sem exigir que ele desative gatilhos inteiros para isso.


Padrões de exclusão por emulador

Contexto: nem todo arquivo sob as pastas monitoradas deve ser sincronizado — caches e temporários específicos de um emulador não têm valor de save e infláveis desnecessariamente o volume sincronizado.

Escolha: cada EmulatorProfile carrega exclude_patterns (glob), com um default por emulador vindo do catálogo (profiles.toml) e editável pelo usuário por emulador.

Justificativa: mantém o filtro próximo de onde o perfil já é definido, em vez de uma lista global — cada emulador tem seus próprios arquivos de cache/temporário.


Process watcher: abertura imediata, fechamento com debounce

Contexto: o watcher de sysinfo ocasionalmente não lista um processo num tick, e emuladores spawnam processos auxiliares — ambos causam flapping. Mas os dois gatilhos têm urgências opostas.

Escolha: EmulatorStarted é emitido no primeiro tick em que o processo aparece; EmulatorStopped só após alguns ticks consecutivos ausente. A máquina de estados (RunStateTracker::reconcile) é pura, sem sysinfo.

Justificativa: baixar os saves do Drive (abertura → Drive → Local) deve acontecer o quanto antes, antes de o jogo ler os arquivos — atraso aqui é prejudicial. Já declarar "fechou" cedo demais dispararia um upload Local → Drive no meio de um flicker, então vale esperar a confirmação. Separar a lógica pura do sysinfo torna o debounce 100% testável. Alternativa (debounce simétrico) rejeitada por atrasar o download de abertura sem ganho.


Watcher: sysinfo síncrono em spawn_blocking, estado dinâmico via SQLite

Contexto: refresh_processes é bloqueante; a spec pede loop tokio::time::interval + mpsc. A lista de emuladores a monitorar muda em runtime (add_emulator/remove_emulator).

Escolha: o System e o RunStateTracker viajam para dentro de um spawn_blocking a cada tick e voltam com os eventos; o produtor relê os emuladores do SQLite a cada tick. Refresh com ProcessRefreshKind::nothing() (só o nome do processo).

Justificativa: mover o estado para o thread bloqueante mantém o runtime async livre sem recriar o System (caro) a cada poll. Reler o SQLite a cada tick é barato (local, poucos registros) e dispensa um canal extra de invalidação. O refresh mínimo mantém o tick leve.


rustls em vez de OpenSSL

Contexto: o TLS padrão do reqwest exige OpenSSL do sistema.

Escolha: reqwest com rustls-tls e default-features = false.

Justificativa: TLS puro Rust, mesma stack em Windows/Linux/macOS, sem dependência de biblioteca de sistema — melhor para distribuição, não só para o dev no WSL.


Retry centralizado no transporte

Contexto: a regra exige retry exponencial (máx 3) em cada chamada ao Drive.

Escolha: um único send_with_retry em drive/client.rs por onde passa toda chamada; a closure build reconstrói o request a cada tentativa.

Justificativa: evita espalhar lógica de retry por dezenas de chamadas. Trata 401 (renova token), 429/403-rate-limit/5xx e falha de rede de forma uniforme. Backoff 500ms/1s/2s + jitter. Concorrência de transferências limitada por semáforo lógico (buffer_unordered).


App vive na tray; fechar a janela ≠ sair

Contexto: o gatilho "sync ao fechar o Slot2Sync" precisa rodar de forma confiável.

Escolha: fechar a janela (WindowEvent::CloseRequested) apenas a esconde (prevent_close + hide); o app continua na bandeja. O sync de despedida (TRIGGER_SHUTDOWN) roda no handler do menu "Sair", imediatamente antes de app.exit(0).

Justificativa: como fechar a janela só minimiza para a tray, o único caminho de saída real passa pelo "Sair" — então o sync de despedida sempre executa. Coloquei o sync nesse handler em vez do RunEvent::ExitRequested (a ideia inicial) porque é uma saída intencional e controlável: evita a dança de prevent_exit + re-disparar o exit depois do sync async. Todas as operações de tray/janela são feitas no Rust, então não exigem permissões novas nas capabilities.


Último sync em célula compartilhada

Contexto: a UI mostra o "último sync", mas o startup sync roda antes de a tela montar e o estado do React se perde se o app reiniciar.

Escolha: Arc<Mutex<Option<LastSync>>> compartilhado entre o SyncEngine (escreve ao concluir, antes de emitir sync:completed) e o AppState (lê via get_last_sync). A UI busca no mount e atualiza ao vivo pelo evento.

Justificativa: cobre o mount tardio sem persistir nada (o estado é efêmero por execução, e cada inicialização gera um sync novo de qualquer forma). Gravar antes de emitir o evento garante que o get_last_sync disparado no sync:completed seja consistente. Alternativa (nova tabela SQLite + migração) rejeitada por excesso para um dado volátil.


Notificações de erro no backend

Contexto: a spec cita o plugin JS @tauri-apps/plugin-notification para erros críticos de sync.

Escolha: disparar a notificação no backend (NotificationExt do Rust), no mesmo ponto em que o engine emite sync:error.

Justificativa: o sync acontece no backend e precisa notificar mesmo quando a janela está oculta (gatilhos de startup/watcher) ou durante o sync de despedida no shutdown, quando o webview pode já não estar responsivo. Disparar do frontend dependeria do webview vivo. O plugin continua inicializado no JS, então a alternativa pelo frontend segue disponível se necessário.


Tipos compartilhados espelhados manualmente

Contexto: drift entre struct Rust e interface TS quebra em runtime, não em compile time.

Escolha: espelhamento manual concentrado em src/types/ipc.ts (TS) e nas structs com #[serde(rename_all = "camelCase")] (Rust), com testes de serialização.

Justificativa: para a quantidade de tipos do início do projeto, manual + testes era suficiente e sem dependência extra. O drift do file_busy (encontrado numa revisão anterior da documentação) mostrou o risco — daí os testes de serialização e a centralização num arquivo só de cada lado. A boundary cresceu bastante desde então (dezenas de comandos hoje); a decisão de migrar ou não para geração automática está registrada, em aberto, em Geração automática da boundary IPC.