code.pablomurad.com

skull-browser

Keyboard-driven browser for the small web, with native Gopher and Gemini

docs: everything in English, and a security model in the README

The comments written during this fork were in Portuguese, in ten files
across Lua, C and shell. They are English now, and so is every message
setup-deps.sh prints. docs/decisoes is docs/decisions, and the decision
record about staying on webkit2gtk-4.1 is translated with it.

The README gains three sections.

Security model states what is on and off by default, and then says the
part that matters: there is no sandbox around Lua. A module under
~/.config/skull runs with everything the user account has, and so does
a userscript installed with :usinstall. Saved form data is plain Lua
the browser executes on every page load, 0600 and host-anchored but not
encrypted, and it says so rather than implying otherwise. Certificate
exceptions, what they cover and how to see and revoke them.

Extending covers the tree layout, writing a module, adding a
skull:// page in ten lines, and adding binds and commands.

Contributing covers the build, the suite, the style checks that fail
the build, where tests live and what a commit message looks like.

Two claims in the README were wrong and are corrected: there is no `?`
binding for help, only :help, and run-tests takes no arguments.
author
Pablo Murad
date
commit
15fab0933405beee5e82e180b446e4875992de21
parent
8fc4eacd99c3540af292e8c6a33c35ce405c6168
changes
11 files +255 −129
@@ -98,6 +98,132 @@ Requires `luassert`. The suite covers the Lua modules and the C bindings.
 
 
+## Security model
+
+Worth reading before you trust this with anything.
+
+### What it does by default
+
+    third-party cookies      blocked
+    DNS prefetching          off
+    WebGL, WebAudio          off
+    media capture            off
+    clipboard access by JS   denied
+    popups by JS             denied
+    developer tools          off
+    page permissions         denied
+    typed addresses          https, never http
+    Referer, cross-domain    dropped
+    Referer, same domain     cut back to the origin, no path or query
+    Referer on a downgrade   dropped
+    files written on disk    0600, the process runs under umask 0077
+    ad and tracker lists     EasyList, EasyPrivacy, EasyList Portuguese
+
+### What it does not do
+
+There is no sandbox around Lua. A module under `~/.config/skull/` runs in the
+browser process with every power your user account has: it can read your files,
+open sockets and start programs. That is the same deal as a window manager
+config, and it is what makes the browser worth using, but it means **a Lua
+module you did not read is a program you did not read**.
+
+The same applies to userscripts installed with `:usinstall`. They reach the
+bridge into Lua, so treat one from a web page as you would treat a shell script
+from a web page.
+
+JavaScript is on by default. Turn it off globally or per domain in `rc.lua`:
+
+    settings.webview.enable_javascript = false
+    settings.on["example.com"].webview.enable_javascript = true
+
+Saved form data, including passwords, is stored in `~/.local/share/skull/`
+as plain Lua that the browser executes on every page load. The file is 0600
+and the pattern is anchored to the page host, but it is not encrypted. If that
+matters to you, use a password manager and do not use `:formfiller`.
+
+### Certificates
+
+When a certificate fails verification you can trust it anyway. That choice
+covers one certificate on one host, not the host in general, so a different
+certificate on the same host warns again. The exception lapses after 90 days.
+
+    gC          list the stored exceptions
+    :certs      same thing
+
+While an exception is in force the status bar shows `(exception)` rather than
+`(trust)`, because the connection is not verified, it is excused.
+
+### Reporting something
+
+Open an issue at <https://github.com/runawaydevil/skull-browser/issues>. If it
+is a vulnerability rather than a bug, say so in the title and leave out the
+working payload until it is fixed.
+
+
+## Extending
+
+Everything above the C core is Lua, and the Lua is on your disk.
+
+    lib/             the modules that make up the browser
+    lib/*_wm.lua     modules that run inside WebKit's render process
+    lib/lousy/       widgets and utilities shared by the rest
+    config/rc.lua    what gets loaded, and in what order
+    widgets/         the C widgets exposed to Lua
+    clib/            the C libraries exposed to Lua
+
+A module is an ordinary Lua file returning a table. Drop it in
+`~/.config/skull/` and `require` it from your `rc.lua`.
+
+An internal page takes about ten lines:
+
+    local chrome = require "chrome"
+
+    chrome.add("hello", function ()
+        return "<html><body><h1>hello</h1></body></html>"
+    end)
+
+That gives you `skull://hello/`. Pass a table of functions as the fourth
+argument to `chrome.add` and they become callable from the page's JavaScript,
+returning promises. Only pages served from `skull://` can reach them.
+
+Key bindings and commands go through `modes`:
+
+    local modes = require "modes"
+
+    modes.add_binds("normal", {
+        { "^gh$", "Go home.", function (w) w:navigate("skull://newtab/") end },
+    })
+
+    modes.add_cmds({
+        { ":hello", "Say hello.", function (w) w:notify("hello") end },
+    })
+
+Type `:help` in the browser for the generated documentation, which covers
+every module, binding and setting present in your build.
+
+`docs/decisions/` holds the decisions that shaped the fork and the reasoning
+behind them. `001-webkitgtk-api.md` explains why nothing new should be written
+under `extension/`.
+
+
+## Contributing
+
+    make                     build
+    make run-tests           the whole suite
+    make apidoc              regenerate doc/apidocs
+
+The suite includes style checks. `luacheck` runs over every Lua file, trailing
+whitespace fails the build, and every exported module function needs a doc
+comment. Run the tests before sending anything; CI runs the same commands on
+push and on pull requests.
+
+Tests live in `tests/async/`, one file per module, each returning a table of
+`test_*` functions. A fix belongs with a test that fails without it.
+
+Commit messages say what changed and why, in English, present tense, with a
+short subject line and a body when the subject is not enough.
+
+
 ## License
 
 GNU GPLv3. See `COPYING.GPLv3`.
@@ -1,11 +1,11 @@ #!/bin/sh
-# Instala as dependencias de build do Skull Browser.
+# Install the build dependencies for Skull Browser.
 #
-#   sh build-utils/setup-deps.sh          instala e verifica
-#   sh build-utils/setup-deps.sh --check  so verifica, nao instala
+#   sh build-utils/setup-deps.sh          install, then check
+#   sh build-utils/setup-deps.sh --check  check only, install nothing
 #
-# Suporta apt (Debian, Ubuntu), dnf (Fedora) e pacman (Arch, Omarchy).
-# Precisa de sudo para instalar. Repetir e seguro.
+# Handles apt (Debian, Ubuntu), dnf (Fedora) and pacman (Arch, Omarchy).
+# Installing needs sudo. Running it again is safe.
 
 set -eu
 
@@ -14,7 +14,7 @@ CHECK_ONLY=0
 [ "${1:-}" = "--check" ] && CHECK_ONLY=1
 
-# --- que distro e esta -------------------------------------------------------
+# --- which distribution is this -------------------------------------------------------
 
 if command -v apt-get >/dev/null 2>&1; then
     FAMILY=debian
@@ -23,25 +23,25 @@ FAMILY=fedora elif command -v pacman >/dev/null 2>&1; then
     FAMILY=arch
 else
-    echo "Nao encontrei apt, dnf nem pacman." >&2
-    echo "Instale manualmente: gtk3, webkit2gtk 4.1, sqlite3, luajit," >&2
-    echo "lua-filesystem e lua-socket para Lua 5.1, gstreamer." >&2
+    echo "Found neither apt, dnf nor pacman." >&2
+    echo "Install by hand: gtk3, webkit2gtk 4.1, sqlite3, luajit," >&2
+    echo "lua-filesystem and lua-socket for Lua 5.1, gstreamer." >&2
     exit 1
 fi
 
 if [ -r /etc/os-release ]; then
     . /etc/os-release
-    say "Distro: ${PRETTY_NAME:-$FAMILY}  (familia $FAMILY)"
+    say "Distribution: ${PRETTY_NAME:-$FAMILY}  ($FAMILY family)"
 else
-    say "Familia de distro: $FAMILY"
+    say "Distribution family: $FAMILY"
 fi
 
-# --- pacotes por familia -----------------------------------------------------
+# --- packages, per family -----------------------------------------------------
 #
-# O que o config.mk exige via pkg-config: gtk+-3.0, gthread-2.0, webkit2gtk-4.1,
-# sqlite3, javascriptcoregtk-4.1 e luajit (ou lua5.1). Em tempo de execucao o
-# interpretador precisa achar lfs e socket -- e eles tem que ser da ABI 5.1,
-# nao da 5.4, senao o require falha mesmo com o pacote instalado.
+# What config.mk asks pkg-config for: gtk+-3.0, gthread-2.0, webkit2gtk-4.1,
+# sqlite3, javascriptcoregtk-4.1 and luajit (or lua5.1). At run time the
+# interpreter has to find lfs and socket, and those must be built for the 5.1
+# ABI, not 5.4, or require fails even with the package installed.
 
 case "$FAMILY" in
 debian)
@@ -71,33 +71,33 @@ ;; esac
 
 if [ "$CHECK_ONLY" -eq 0 ]; then
-    say "Atualizando indice de pacotes"
+    say "Refreshing the package index"
     $REFRESH
-    say "Instalando"
+    say "Installing"
     # shellcheck disable=SC2086
     $INSTALL $PACKAGES
 fi
 
-# --- verificacao -------------------------------------------------------------
+# --- check -------------------------------------------------------------
 
 missing=0
 
-say "Pacotes pkg-config"
+say "pkg-config packages"
 if ! command -v pkg-config >/dev/null 2>&1; then
-    echo "  FALTA   pkg-config"
+    echo "  MISSING pkg-config"
     missing=1
 else
     for pkg in gtk+-3.0 gthread-2.0 webkit2gtk-4.1 sqlite3 javascriptcoregtk-4.1; do
         if pkg-config --exists "$pkg"; then
             printf '  ok      %-24s %s\n' "$pkg" "$(pkg-config --modversion "$pkg")"
         else
-            printf '  FALTA   %s\n' "$pkg"
+            printf '  MISSING %s\n' "$pkg"
             missing=1
         fi
     done
 fi
 
-say "Interpretador Lua"
+say "Lua interpreter"
 LUA_BIN=
 for cand in luajit luajit51 lua5.1 lua-5.1 lua51; do
     if command -v "$cand" >/dev/null 2>&1 && "$cand" -v 2>&1 | grep -Eq '^Lua 5\.1|^LuaJIT'; then
@@ -108,35 +108,35 @@ done if [ -n "$LUA_BIN" ]; then
     printf '  ok      %-24s %s\n' "$LUA_BIN" "$($LUA_BIN -v 2>&1 | head -1)"
 else
-    echo "  FALTA   luajit ou lua5.1 (config.mk nao aceita 5.2+)"
+    echo "  MISSING luajit or lua5.1 (config.mk does not take 5.2+)"
     missing=1
 fi
 
-say "Modulos Lua"
+say "Lua modules"
 if [ -n "$LUA_BIN" ]; then
     for mod in lfs socket; do
         if "$LUA_BIN" -e "require('$mod')" >/dev/null 2>&1; then
             printf '  ok      %s\n' "$mod"
         else
-            printf '  FALTA   %-8s (lfs e obrigatorio; socket move o gopher)\n' "$mod"
+            printf '  MISSING %-8s (lfs is required; socket drives gopher)\n' "$mod"
             missing=1
         fi
     done
 else
-    echo "  (pulado -- sem interpretador)"
+    echo "  (skipped, no interpreter)"
 fi
 
 if [ "$missing" -ne 0 ]; then
-    say "Incompleto. Resolva os itens marcados FALTA."
+    say "Incomplete. Deal with the items marked MISSING."
     [ "$FAMILY" = fedora ] && cat <<'EOF'
 
-  No Fedora, lua-filesystem e lua-socket sao compilados para Lua 5.4,
-  e luassert nao e empacotado.
-  O LuaJIT nao os enxerga. Saidas possiveis:
+  On Fedora, lua-filesystem and lua-socket are built for Lua 5.4, and
+  luassert is not packaged at all. LuaJIT cannot see any of them.
+  Two ways out:
     - luarocks --lua-version=5.1 install luafilesystem luasocket luassert
-    - ou construir contra lua5.1 em vez de luajit:  make USE_LUAJIT=0
+    - or build against lua5.1 instead of luajit:  make USE_LUAJIT=0
 EOF
     exit 1
 fi
 
-say "Ambiente pronto.  Proximo:  make -j\$(nproc)"
+say "Environment ready.  Next:  make -j\$(nproc)"
@@ -0,0 +1,69 @@+# 001. Stay on webkit2gtk-4.1
+
+**Date:** 2026-09-19
+**Status:** accepted
+
+## The problem
+
+Skull Browser builds against `webkit2gtk-4.1`, the WebKitGTK API for GTK 3.
+The current upstream API is `webkitgtk-6.0`, which requires GTK 4 and libsoup 3.
+
+Two sentences from the official documentation set out the risk. From the
+migration guide for 6.0:
+
+> All APIs that were previously deprecated in webkit2gtk-4.0 and webkit2gtk-4.1
+> have been removed. [...] It also includes the entire GObject DOM API (e.g.
+> `WebKitDOMDocument`), which has been **removed without replacement**. Use
+> JavaScript to interact with and manipulate the DOM instead.
+
+And, in the same document:
+
+> Beware that as of WebKitGTK 2.40, **the entire web process API may
+> unfortunately be removed in the future.**
+
+## Why that matters here
+
+The second sentence is the serious one. The luakit architecture, and so ours,
+is built on exactly that API:
+
+- `extension/` is around 1,900 lines of C whose whole purpose is to run a Lua
+  VM **inside** WebKit's rendering process
+- the ten `lib/*_wm.lua` modules, around 1,480 lines, assume synchronous access
+  to the DOM from that process
+- `extension/scroll.c` goes as far as defining `WEBKIT_DOM_USE_UNSTABLE_API` to
+  read the scroll position
+
+If the web process API goes away, that is not an adjustment. Every synchronous
+`dom_element` property becomes an asynchronous round trip, and all ten modules
+have to be rethought.
+
+Moving to 6.0 also drags GTK 3 to GTK 4 along with it, a port of its own that
+is larger than any phase planned in this project so far.
+
+## Decision
+
+**Stay on `webkit2gtk-4.1`.** Do not migrate now.
+
+The real pressure is low. 4.1 is supported upstream, Ubuntu 24.04 ships
+WebKitGTK 2.52.6 built against it, and the build passes with deprecation
+warnings only, no errors. No date has been announced for the removal.
+
+## What follows from it
+
+**Do not add new code under `extension/`.**
+
+Code written there is code with an expiry date. When something can be done in
+the interface process instead, or through `eval_js`, that is the way to do it,
+even when it looks clumsier today.
+
+## When to revisit
+
+- when a distribution we care about stops packaging `webkit2gtk-4.1`
+- when upstream announces a date for removing the web process API
+- if something comes up that would force `extension/` to grow, at which point
+  the cost of migrating has to be weighed against the cost of staying
+
+## Sources
+
+- WebKit 6.0: Migrating WebKitGTK Applications to GTK 4 (official documentation)
+- WebKitGTK API Versions Demystified, Michael Catanzaro, 2025-04-28
@@ -1,70 +0,0 @@-# 001 — Ficar no webkit2gtk-4.1
-
-**Data:** 2026-09-19
-**Situação:** aceita
-
-## O problema
-
-O Skull Browser compila contra `webkit2gtk-4.1`, a API do WebKitGTK para GTK 3.
-A versão atual da API upstream é `webkitgtk-6.0`, que exige GTK 4 e libsoup 3.
-
-Duas frases da documentação oficial definem o risco.
-
-Do guia de migração para a 6.0:
-
-> All APIs that were previously deprecated in webkit2gtk-4.0 and webkit2gtk-4.1
-> have been removed. [...] It also includes the entire GObject DOM API (e.g.
-> `WebKitDOMDocument`), which has been **removed without replacement**. Use
-> JavaScript to interact with and manipulate the DOM instead.
-
-E, no mesmo documento:
-
-> Beware that as of WebKitGTK 2.40, **the entire web process API may
-> unfortunately be removed in the future.**
-
-## Por que isso importa aqui
-
-A segunda frase é a séria. A arquitetura do luakit — e portanto a nossa — é
-construída em cima exatamente dessa API:
-
-- `extension/` são ~1.900 linhas de C que existem para rodar uma VM Lua
-  **dentro** do processo de renderização do WebKit
-- os 10 módulos `lib/*_wm.lua` (~1.480 linhas) assumem acesso síncrono ao DOM
-  a partir desse processo
-- `extension/scroll.c` chega a definir `WEBKIT_DOM_USE_UNSTABLE_API` para obter
-  a posição de rolagem
-
-Se a API de processo web for removida, isso não é ajuste: cada propriedade
-síncrona de `dom_element` vira uma ida e volta assíncrona, e os 10 módulos
-precisam ser repensados.
-
-Migrar para a 6.0 também arrasta GTK 3 → GTK 4, que é um porte independente e
-maior que qualquer fase já planejada neste projeto.
-
-## Decisão
-
-**Ficar no `webkit2gtk-4.1`.** Não migrar agora.
-
-A pressão real é baixa: a 4.1 é suportada upstream, o Ubuntu 24.04 entrega
-WebKitGTK 2.52.6 com ela, e o build passa com avisos de depreciação apenas —
-nenhum erro. Não há prazo anunciado para a remoção.
-
-## Consequência operacional
-
-**Não adicionar código novo em `extension/`.**
-
-Código escrito ali é código com prazo de validade. Quando uma funcionalidade
-puder ser feita no processo da interface, ou via `eval_js`, esse é o caminho —
-mesmo que pareça mais desajeitado hoje.
-
-## Quando revisar
-
-- Quando uma distro que nos interessa parar de empacotar `webkit2gtk-4.1`
-- Quando o upstream anunciar data para remover a API de processo web
-- Se surgir necessidade que force crescer `extension/` — aí o custo da migração
-  precisa ser comparado com o custo de continuar
-
-## Fontes
-
-- WebKit 6.0: Migrating WebKitGTK Applications to GTK 4 (documentação oficial)
-- WebKitGTK API Versions Demystified — Michael Catanzaro, 2025-04-28
@@ -39,7 +39,7 @@ background:             radial-gradient(ellipse at 50% 28%, #10281b 0%, transparent 62%),
             var(--bg);
     }
-    /* Varredura de tubo: sutil, so para o fundo nao ficar morto. */
+    /* Scanline sweep: faint, just enough that the background is not dead. */
     body::after {
         content: "";
         position: fixed;
@@ -99,8 +99,8 @@ border-top: 1px solid rgba(93,100,112,0.22);     }
 ]===]
 
--- O cranio, embutido: a pagina precisa funcionar sem depender de um handler
--- de recurso que hoje nao existe.
+-- The skull is inlined: the page has to render without depending on a
+-- resource handler that does not exist.
 local mark_svg = [==[
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
 <rect width="64" height="64" rx="14" fill="#050B07"/>
@@ -431,9 +431,9 @@ end 
     if not no_sync and not single_list then
         adblock_wm:emit_signal("update_rules", _M.rules)
-        -- update_rules troca a tabela de regras mas nao mexe em enabled_rules,
-        -- entao um processo web existente ficava com a lista carregada e
-        -- desligada ate alguem alternar na mao. Reemitir o estado resolve.
+        -- update_rules swaps the rule table but leaves enabled_rules alone,
+        -- so an existing web process kept the list loaded and switched off
+        -- until somebody toggled it by hand. Re-sending the state fixes it.
         for name, list in pairs(_M.rules) do
             adblock_wm:emit_signal("list_set_enabled", name,
                 util.table.hasitem(list.opts or {}, "Enabled") and true or false)
@@ -82,7 +82,7 @@ finish()     end)
 
     if not ok then
-        msg.warn("adblock: nao consegui executar curl: %s", tostring(err))
+        msg.warn("adblock: could not run curl: %s", tostring(err))
         finish()
     end
 end
@@ -15,9 +15,10 @@ local history = require("history") 
 local _M = {}
 
--- Valores vindos de fora -- URI, mensagem do WebKit, erro de socket -- nao
--- podem chegar crus ao template: as chaves sao lidas como placeholder e o
--- resto vira markup vivo. Local de proposito: nao e API publica.
+-- Values from outside, meaning the uri, a WebKit message or a socket error,
+-- cannot reach the template raw: braces in them are read as placeholders and
+-- the rest becomes live markup.
+--
 -- Fields that legitimately hold HTML. Everything else reaching the template is
 -- treated as data.
 local markup_fields = { content = true, style = true, buttons = true }
@@ -345,10 +346,10 @@ if type(msg) == "string" then msg = {msg} end         error_page_info.msg = "<p>" .. table.concat(msg, "</p><p>") .. "</p>"
     end
 
-    -- Substituicao repetida porque um valor pode conter placeholder de outro
-    -- ({content} traz {uri} e {msg}). O limite existe porque gsub com tabela
-    -- conta a ocorrencia mesmo quando a chave nao existe e nada e substituido:
-    -- sem o teto, um unico {palavra} desconhecido trava aqui para sempre.
+    -- Substitution repeats because one value can carry another's placeholder
+    -- ({content} brings {uri} and {msg} with it). The cap is there because
+    -- gsub with a table counts an occurrence even when the key is missing and
+    -- nothing is replaced: without it, one unknown {word} spins here forever.
     local html = _M.html_template
     for _ = 1, 8 do
         local nsub
@@ -77,8 +77,8 @@ fields[#fields + 1] = chunk     end
     ret.display_string = fields[1] or ""
     ret.selector = fields[2] or ""
-    -- Campos vem crus do servidor remoto. A porta ja era filtrada; o host
-    -- nao era, e entrava direto num atributo href.
+    -- The fields come raw from the remote server. The port was already
+    -- filtered; the host was not, and it went straight into an href.
     ret.host = (fields[3] or url.host):gsub("[^%w%.%-]", "")
     if ret.host == "" then ret.host = url.host end
     ret.port = (fields[4] or tostring(url.port)):gsub("[^0-9]", "")
@@ -470,8 +470,8 @@ end 
 -- forward request to error_page module
 local function show_error_page(v, request, reason)
-    -- error_page expande {palavra} ate nao sobrar nenhuma, entao chave crua
-    -- vinda de erro de socket seria engolida como placeholder.
+    -- error_page expands {word} until none are left, so a brace coming out
+    -- of a socket error would be swallowed as a placeholder.
     reason = lousy.util.escape(tostring(reason))
         :gsub("{", "&#123;"):gsub("}", "&#125;")
     pcall(error_page.show_error_page, v, {
@@ -125,9 +125,9 @@ XDG_CONFIG_DIRS = "",         DISPLAY = xvfb_display
     }
 
-    -- env -i limpa tudo, entao repassar os caminhos de modulo Lua quando
-    -- existirem: sem isso nao da para rodar a suite com luassert instalado
-    -- fora do prefixo do sistema.
+    -- env -i clears everything, so pass the Lua module paths back through
+    -- when they are set. Without this the suite cannot run with luassert
+    -- installed outside the system prefix.
     for _, var in ipairs({ "LUA_PATH", "LUA_CPATH", "LD_LIBRARY_PATH" }) do
         local val = util.getenv(var)
         if val and val ~= "" then env[var] = "'" .. val .. "'" end
@@ -217,9 +217,9 @@ end     end
 end
 
--- Reaproveitar um display existente quando pedido. Xvfb exige /tmp/.X11-unix
--- com dono e modo proprios e um xkbcomp em /usr/bin; nem todo ambiente tem
--- isso (WSLg, conteineres), e ali um X server ja esta no ar.
+-- Reuse an existing display when asked to. Xvfb wants /tmp/.X11-unix with
+-- its own owner and mode, and an xkbcomp in /usr/bin. Not every environment
+-- has either (WSLg, containers), and in those an X server is already up.
 local reuse_display = os.getenv("SKULL_TEST_DISPLAY")
 
 -- Find a free server number
@@ -1296,8 +1296,8 @@ const gchar *uri = webkit_uri_scheme_request_get_uri(request); 
     WebKitWebView *view = webkit_uri_scheme_request_get_web_view(request);
     if (!view) {
-        /* Sem web view (worker, inspector) nao ha handler possivel. Falhar
-         * explicitamente, senao o carregamento fica pendurado. */
+        /* With no web view (a worker, the inspector) there is no handler to
+         * call. Fail loudly, or the load hangs. */
         GError *e = g_error_new(G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
                 "no web view to serve scheme '%s'", scheme);
         webkit_uri_scheme_request_finish_error(request, e);