O Jogo Saiu do Navegador
TD Survivors é um jogo de navegador e vai continuar sendo, mas agora também é um app de desktop pra Windows e Linux, empacotado automaticamente pela CI a cada push na main e publicado em tds.doug.pt/downloads. Isso entrou como três tickets: fazer o bundle rodar fora de um navegador, embrulhar em Electron e fazer a CI buildar sem atrasar o deploy da web. Zero mudanças de gameplay nos três, e um conserto de segurança vergonhoso no final.
Ticket A: rodar fora do navegador
O build web assume que é servido por um servidor web num caminho raiz, e um shell de desktop servindo arquivos por um protocolo customizado não é nenhuma das duas coisas. Quatro peças de fundação, todas entregues antes de existir uma linha de Electron:
- Caminhos de asset relativos. Um
vite.config.tscombase: './'prodist/referenciar os assets de forma relativa, mais tirar a barra inicial dos caminhos de favicon, fonte e cursor noindex.htmle nosrc/cursor.ts. Um protocoloapp://customizado resolve caminho absoluto de um jeito que você não quer. - Uma fachada de Steam. O
src/lib/steam.tsé um no-op tipado:isSteam,setRichPresence,openInviteDialog,cloudRead/cloudWrite, todos lendo uma ponte opcionalwindow.steamque ainda não existe. Nada emsrc/importa Steam de verdade. A ideia é que o formato seja decidido agora, uma vez, em vez de ser inventado na correria depois. - Um link de convite que sabe onde está. O
lib/shareLink.tsé uma função pura que devolve a URL completa da sala em origens http/https e só o código cru da sala em qualquer outra, ligada ao lobby. Uma origemapp://estava produzindo um link que ninguém conseguia colar em lugar nenhum. - Um frame driver. O
lib/frameDriver.tsarma umrequestAnimationFramee um timer juntos; quem disparar primeiro conduz o frame, com uma trava de "já foi" pra nunca sair em dobro. Ele é instalado por cima dowindow.requestAnimationFrameantes doengineInit, protegido por detecção de desktop, então uma janela de desktop minimizada continua avançando a simulação enquanto o build web segue no rAF nativo. Navegador estrangular o rAF numa aba em segundo plano é comportamento correto; um host de co-op travar a sessão inteira porque minimizou a janela não é.
Ticket B: o shell do Electron
O shell mora em desktop/, dentro do projeto, de propósito, pra detecção de "o td-survivors mudou?" que a CI já tinha cobrir ele sem precisar de regra nova.
O main.js registra um esquema privilegiado app:// antes do app-ready (standard, secure, supportFetchAPI, stream) e serve o ../dist empacotado via protocol.handle com uma proteção contra path traversal, usando net.fetch pra mídia funcionar em stream e o Content-Type sair certo. A janela é 1280x720 e endurecida do jeito de sempre: context isolation ligado, sandbox ligado, node integration desligado, sem menu de aplicação, backgroundColor #111111 com show-on-ready pra não ter aquele flash branco ao abrir. O fullscreen continua disponível pra tecla F do jogo.
Depois vem uma pilha de flags, em dois grupos. Segundo plano: backgroundThrottling desligado, mais disable-background-timer-throttling, disable-renderer-backgrounding, disable-backgrounding-occluded-windows e desabilitar o CalculateNativeWinOcclusion, que é a outra metade do trabalho do frame driver: janela ocluída ou minimizada tem que continuar simulando. Performance: ignore-gpu-blocklist, enable-gpu-rasterization, enable-zero-copy, rasterização de canvas fora do processo, forçar GPU de alta performance e cache de código do V8. Um powerSaveBlocker impede a tela de dormir enquanto a janela existe, porque ficar assistindo a sua defesa segurando é uma forma legítima de jogar tower defense.
O preload.js expõe o window.steam pelo contextBridge como no-ops que batem com a interface do Ticket A, que é a costura onde uma ponte real do Steam encaixa depois. O electron-builder.yml produz um zip de Windows (x64) e AppImage mais tar.gz de Linux (x64), empacotados em asar, compressão máxima, puxando o jogo do ../dist.
Ticket C: a CI builda em segundo plano
Empacotar Electron pra duas plataformas leva minutos, e o deploy da web leva segundos. Fazer o segundo esperar pelo primeiro seria um péssimo negócio pra sempre.
Então, num push na main que toca em td-survivors/, o deploy.js tira um snapshot da árvore com git archive e dispara um container electronuserland/builder:wine com docker run -d, caches nomeados e um log em tee. Ele não espera. O deploy da web termina e o build de desktop chega quando chegar, que é exatamente a prioridade certa.
Por dentro, o desktop/ci-build.sh builda o bundle web, roda o electron-builder pros dois alvos, publica os artefatos como td-survivors-<plataforma>-<data>-<sha8>.<ext>, regenera uma página de índice e poda tudo menos os cinco builds mais recentes. A config do nginx do jogo ganhou um bloco location /downloads/ (autoindex ligado, try_files caindo em 404) colocado antes do fallback de SPA, senão um arquivo inexistente devolveria prestativamente o HTML do jogo em vez de um 404, o que quebra gerenciador de download de formas confusas.
E aí ele serviu o código-fonte
A primeira versão escrevia os artefatos em /ci/artifacts/td-survivors e o container do jogo montava esse diretório inteiro em /downloads. Esse diretório tem irmãos: o work/, que é um git archive completo da árvore de código, e o logs/. Acessar https://tds.doug.pt/downloads/work/<sha>/td-survivors/src/ devolvia 200 e servia o fonte.
O conserto não é "adiciona regra pra esconder isso", é tornar estruturalmente impossível: o build agora publica num subdiretório public/, e o compose monta só ele. Os logs ficam em logs/, fora do mount, e não importa o que o build deixe jogado na área de trabalho dele, o HTTP não alcança. Verificado na VPS: /downloads/ serve o índice, /downloads/work/ e /downloads/logs/ devolvem 404, e o zip de Windows de 159 MB baixa inteiro, com checagem de integridade do zip ok e o TD Survivors.exe lá dentro.
Um mount é uma permissão. A pergunta certa nunca é "o que eu preciso esconder aqui", é "qual é a menor coisa que eu consigo expor".
Então é isso: o jogo roda offline, em duas plataformas, reconstruído a cada push, com cinco builds de histórico. Só falta um nome.