Glaze, WebView desktop em Go sem CGO

Abrir uma janela com HTML em Go quase sempre cobra um pedágio em C.

Ou você liga o cgo e arrasta uma toolchain C pro projeto, ou alguém embute uma .dll/.so/.dylib compilada no binário e extrai em disco na primeira execução. Os dois funcionam. E os dois te tiram justamente o que eu gosto no ecossistema: o go build que cross-compila pra três sistemas sem pensar, o build reprodutível, o go install que roda pra quem clonou o repo. É a primeira coisa que vai embora quando o C entra.

O Glaze começou fazendo a segunda opção. Era um fork do go-webview: embutia a biblioteca C++ do webview/webview compilada, extraía o blob pra um diretório temporário e carregava de lá. Tinha até verificação BLAKE2b-256 do arquivo extraído contra os bytes embutidos, pra ninguém trocar a lib no disco entre a extração e o load. Funcionava. Mas é muita cerimônia pra abrir uma janela: um blob binário por plataforma, gerado num job de CI, extraído em runtime, e um hash pra validar um arquivo que eu mesmo acabei de escrever.

A virada foi parar de carregar uma lib minha e passar a chamar a WebView que o sistema operacional já tem. macOS vem com WKWebView. Windows 10/11 traz o runtime do WebView2. No Linux é um WebKitGTK de sistema, que você declara como dependência em vez de empacotar. Em nenhum dos casos a lib nativa precisa ir junto no binário. Ela já está na máquina.

O que faltava era falar com ela sem cgo. É aí que entra o purego.

purego é FFI sem cgo

A ideia do purego é abrir uma biblioteca compartilhada em runtime e chamar função de C direto, sem import "C", sem compilador C no caminho. No Linux e no macOS é dlopen/dlsym por baixo:

lib, _ := purego.Dlopen("libwebkitgtk-6.0.so.4", purego.RTLD_NOW|purego.RTLD_GLOBAL)

var webkitWebViewNew func() uintptr
purego.RegisterLibFunc(&webkitWebViewNew, lib, "webkit_web_view_new")

Depois disso webkitWebViewNew() é uma função Go normal que chama o símbolo de C. Nenhum estágio de compilação, nenhum header.

No Windows não tem Dlopen – você resolve os símbolos com LoadLibrary e GetProcAddress e registra do mesmo jeito. Pra callback de C que chama Go de volta tem o NewCallback, que no Windows delega pro syscall.NewCallback da stdlib e acerta a convenção stdcall. E pro macOS existe o pacote objc, que fala com o runtime do Objective-C: registrar classe, criar um método em Go que o AppKit chama de volta, blocks, struct passado por valor. Dá pra montar um NSWindow com um WKWebView dentro inteiro em Go.

Cada sistema tem a sua maldição

Não é de graça. Você troca um blob binário por reimplementar o backend em cada sistema, e cada um cobra o seu preço.

macOS foi o mais limpo. Cocoa via objc, os delegates registrados como classe Go, autorelease pool na mão. Trabalhoso, mas direto.

Linux é C ABI puro, o terreno onde o purego brilha. O detalhe chato é versão. O webview original escolhe GTK3 ou GTK4 em tempo de compilação; em runtime você não tem esse luxo. Então o Glaze detecta qual stack está instalado – libwebkitgtk-6.0 (GTK4) ou libwebkit2gtk-4.1/4.0 (GTK3) – e troca as chamadas, porque a aridade de algumas funções mudou entre as duas versões.

Windows foi o inferno. WebView2 não é uma API C boba, é COM. Você chama método por índice numa vtable de ponteiros, implementa as interfaces de callback do lado de cá (uma vtable montada em Go com QueryInterface/AddRef/Release/Invoke), e ainda cuida do ref-count de um objeto que o garbage collector não conhece. E pra manter o “zero DLL” sem embutir o WebView2Loader.dll, o Glaze reimplementa a descoberta do loader: acha a DLL do runtime pelo registro do Windows e chama um export interno do Edge pra criar o ambiente. É o que o loader oficial faz por baixo. O preço é que esse export é interno e não-documentado, e a Microsoft pode renomear ou remover. Se sumir, dá erro claro em vez de explodir feio.

Uma regra atravessa os três backends: nenhum ponteiro Go cruza pra C. O engine é identificado por um id inteiro guardado num mapa do lado Go, e o que vai pro C é só o inteiro. Ponteiro de Go na mão do C é convite pro GC mover a memória embaixo dele.

O banco de testes é CI

Tem um detalhe nada glamouroso: eu desenvolvo no macOS. Linux e Windows eu não tinha como rodar na mão. E bug de ABI não aparece no cross-compile – compila lindo e quebra na execução.

Então o banco de testes virou container e CI: GitHub Actions rodando os três sistemas (macOS, Windows, Linux em GTK3 e GTK4, amd64 e arm64), mais revisão adversarial do código COM antes de gastar ciclo de CI no Windows. Foi assim que apareceu, por exemplo, que g_signal_handlers_disconnect_by_data não é símbolo exportado, é macro do GObject – undefined symbol que só estoura em runtime, dentro de um container. Ou que passar um RECT por valor pro WebView2 funciona no amd64 e quebra no arm64, porque o struct de 16 bytes vai por referência num e empacotado em dois registradores no outro.

O que sobra

No fim, o que fica é o que eu queria desde o começo:

  • CGO_ENABLED=0 em tudo;
  • cross-compile pros três sistemas direto do go build, sem toolchain C;
  • binário que não carrega nenhuma lib nativa junto;
  • go install github.com/crgimenes/glaze funcionando pra quem clonou, sem ritual.

O que você passa a depender é o runtime estar presente: nada extra no macOS, um WebKitGTK no Linux, o Edge WebView2 Runtime no Windows (que já vem no 10/11). É uma troca honesta, dependência de sistema declarada no lugar de dependência embutida e disfarçada.

O Glaze não “usa” mais o webview/webview em runtime. Virou um port: a mesma ideia, reescrita em Go puro sobre purego, sem um byte de C++ no binário. Trocar um blob compilado por três backends escritos à mão é mais trabalho, e foi. Mas o que sai do go build agora fala COM no Windows, objc no macOS e GTK no Linux sem um import "C" em lugar nenhum.

Glaze no GitHub


Cesar Gimenes

Última modificação
Projetos:
Tags: