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.
@@ -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("{", "{"):gsub("}", "}")
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);