Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions images/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,44 @@
- `cd kernel-images/images/chromium-headful`
- Build and run the docker image with `./build-docker.sh && ENABLE_WEBRTC=true ./run-docker.sh`
- Open http://localhost:8080/ in your browser

### Experimental native Wayland Chromium

Set `KERNEL_WAYLAND_PURE=true` when running the headful image to launch
Chromium with the native Wayland Ozone backend on a headless Weston output.
This mode does not start Xorg, Mutter, Neko, or the X11 capture/input path;
use CDP for browser interaction and screenshots while benchmarking it.

```sh
KERNEL_WAYLAND_PURE=true ./run-docker.sh
```

`KERNEL_WAYLAND_NESTED=true` remains available for comparing native Wayland
Chromium while retaining the existing X11 capture and input path.

Set `ENABLE_WEBRTC=true ENABLE_WAYLAND_WEBRTC=true` in pure mode to run Neko
against the Wayland capture and `/dev/uinput` input backends. This requires
the Neko build with Wayland support and a wlroots compositor exposing
screencopy and output-management.

#### Browser-only benchmark

Ten fresh-container trials per mode, using the same Chromium flags and a
1920x1080 configuration. The screenshot metric is CDP
`Page.captureScreenshot`; it does not measure product capture or live view.

| metric | X11 | pure Wayland |
| --- | ---: | ---: |
| wrapper readiness (mean) | 2.84s | 2.19s |
| Chromium startup (mean) | 602ms | 565ms |
| CDP evaluation p50 (mean) | 1.06ms | 0.96ms |
| CDP screenshot p50 (mean) | 257ms | 116ms |
| container memory (mean) | 709MiB | 414MiB |

The memory reduction primarily comes from not starting Xorg and Mutter. Pure
mode remains experimental and does not provide the X11 screenshot, input,
recording, or live-view paths.

- Now new endpoint should be available for tests example curl command:
```sh
curl -X POST localhost:444/computer/cursor \
Expand Down
37 changes: 36 additions & 1 deletion images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,27 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX
rm -rf /tmp/ffmpeg*
EOT

FROM docker.io/ubuntu:22.04 AS wayland-recorder-builder

RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential git meson ninja-build pkg-config ca-certificates \
libwayland-dev wayland-protocols libgbm-dev libdrm-dev \
libavutil-dev libavcodec-dev libavdevice-dev libavfilter-dev \
libavformat-dev libswresample-dev libswscale-dev \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --branch v0.6.0 https://github.com/ammen99/wf-recorder.git /tmp/wf-recorder \
&& meson setup /tmp/wf-recorder/build /tmp/wf-recorder \
-Dpulse=disabled -Dpipewire=disabled \
-Ddefault_codec=rawvideo \
&& meson compile -C /tmp/wf-recorder/build \
&& meson install -C /tmp/wf-recorder/build \
&& rm -rf /tmp/wf-recorder
RUN git clone --depth 1 --branch v0.5.0 https://gitlab.freedesktop.org/emersion/wlr-randr.git /tmp/wlr-randr \
&& meson setup /tmp/wlr-randr/build /tmp/wlr-randr \
&& meson compile -C /tmp/wlr-randr/build \
&& meson install -C /tmp/wlr-randr/build \
&& rm -rf /tmp/wlr-randr

FROM ghcr.io/kernel/neko/base:3.0.8-v1.6.0 AS neko
FROM node:22-bullseye-slim AS node-22
FROM docker.io/ubuntu:22.04
Expand Down Expand Up @@ -186,6 +207,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap
imagemagick \
sudo \
mutter \
weston \
curl \
# Network tools
net-tools \
Expand All @@ -197,9 +219,22 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap
xdg-utils \
libvulkan1 \
fontconfig \
unzip && \
unzip \
libavcodec58 \
libavdevice58 \
libavfilter7 \
libavformat58 \
libavutil56 \
libswresample3 \
libswscale5 \
libgbm1 \
libwayland-client0 \
libdrm2 && \
apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=wayland-recorder-builder /usr/local/bin/wf-recorder /usr/local/bin/wf-recorder
COPY --from=wayland-recorder-builder /usr/local/bin/wlr-randr /usr/local/bin/wlr-randr

# Install fonts to match a realistic Ubuntu 22.04 desktop fingerprint.
# A minimal container has only 3 fonts, which is a strong fingerprinting signal
# used by reCAPTCHA Enterprise and other bot detection vendors.
Expand Down
10 changes: 10 additions & 0 deletions images/chromium-headful/run-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ RUN_ARGS=(
--mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags
)

if [[ "${KERNEL_WAYLAND_NESTED:-}" == "true" ]]; then
RUN_ARGS+=( -e KERNEL_WAYLAND_NESTED=true )
fi
if [[ "${KERNEL_WAYLAND_PURE:-}" == "true" ]]; then
RUN_ARGS+=( -e KERNEL_WAYLAND_PURE=true )
fi

if [[ -n "${PLAYWRIGHT_ENGINE:-}" ]]; then
RUN_ARGS+=( -e PLAYWRIGHT_ENGINE="$PLAYWRIGHT_ENGINE" )
fi
Expand All @@ -88,6 +95,9 @@ if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then
echo "Running container with WebRTC"
RUN_ARGS+=( -p 8080:8080 )
RUN_ARGS+=( -e ENABLE_WEBRTC=true )
if [[ "${ENABLE_WAYLAND_WEBRTC:-}" == "true" ]]; then
RUN_ARGS+=( -e ENABLE_WAYLAND_WEBRTC=true )
fi
if [[ -n "${NEKO_ICESERVERS:-}" ]]; then
RUN_ARGS+=( -e NEKO_ICESERVERS="$NEKO_ICESERVERS" )
else
Expand Down
8 changes: 8 additions & 0 deletions images/chromium-headful/supervisor/services/neko-wayland.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[program:neko-wayland]
command=/usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080
autostart=false
autorestart=true
startsecs=0
environment=PULSE_SERVER="unix:/tmp/pulse/native",NEKO_DESKTOP_WAYLAND="true",NEKO_CAPTURE_VIDEO_WAYLAND="true",NEKO_CAPTURE_VIDEO_WAYLAND_RECORDER="wf-recorder",NEKO_DESKTOP_WAYLAND_RESIZE_COMMAND="wlr-randr",NEKO_DESKTOP_WAYLAND_OUTPUT="HEADLESS-1"
stdout_logfile=/var/log/supervisord/neko-wayland
redirect_stderr=true
9 changes: 9 additions & 0 deletions images/chromium-headful/supervisor/services/weston-pure.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[program:weston-pure]
command=/bin/bash -lc 'exec /usr/bin/weston --backend=headless-backend.so --socket=wayland-1 --width="${WIDTH:-1920}" --height="${HEIGHT:-1080}" --use-pixman --idle-time=0 --shell=kiosk-shell.so'
autostart=false
autorestart=true
startsecs=0
user=kernel
environment=HOME="/home/kernel",XDG_RUNTIME_DIR="/tmp/runtime-kernel",WAYLAND_DISPLAY="wayland-1"
stdout_logfile=/var/log/supervisord/weston-pure
redirect_stderr=true
9 changes: 9 additions & 0 deletions images/chromium-headful/supervisor/services/weston.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[program:weston]
command=/bin/bash -lc 'exec /usr/bin/weston --backend=x11-backend.so --socket=wayland-1 --width="${WIDTH:-1920}" --height="${HEIGHT:-1080}" --use-pixman --idle-time=0'
autostart=false
autorestart=true
startsecs=0
user=kernel
environment=DISPLAY=":1",HOME="/home/kernel",XDG_RUNTIME_DIR="/tmp/runtime-kernel",WAYLAND_DISPLAY="wayland-1"
stdout_logfile=/var/log/supervisord/weston
redirect_stderr=true
96 changes: 78 additions & 18 deletions server/cmd/chromium-launcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import (
// connect to the same socket and play into the same sink the daemon sets up.
// Keep them in sync with start-pulseaudio.sh.
const (
waylandRuntimeDir = "/tmp/runtime-kernel"
waylandDisplay = "wayland-1"

// pulseServer is the PulseAudio socket the recorder and chromium share.
pulseServer = "unix:/tmp/pulse/native"
// pulseSink is the null sink chromium plays into; the recorder captures
Expand Down Expand Up @@ -56,21 +59,28 @@ func main() {
// Wait for devtools port to be available (handles SIGKILL socket cleanup delay)
waitForPort(internalPort, 5*time.Second)

// Wait for the X server. The wrapper starts chromium in parallel with
// xorg/xvfb, so the display socket may not be ready yet — without this
// gate chromium would fail on connect and supervisord would restart us.
if d := x11.WaitForDisplay(":1", 20*time.Second); d >= 20*time.Second {
fmt.Fprintf(os.Stderr, "warning: X display :1 not responsive after %s\n", d)
}

// Headful: wait for mutter to register before exec'ing chromium. If
// chromium maps its window with no WM present, the CSD hint it sends has
// no listener; mutter starts later, reparents the existing window, and
// applies default SSD — i.e., the titlebar with the close X. Headless
// has no WM, so skip.
if !*headless {
if d := x11.WaitForMutter(20 * time.Second); d >= 20*time.Second {
fmt.Fprintf(os.Stderr, "warning: mutter not registered after %s\n", d)
nestedWayland := !*headless && strings.EqualFold(strings.TrimSpace(os.Getenv("KERNEL_WAYLAND_NESTED")), "true")
pureWayland := !*headless && strings.EqualFold(strings.TrimSpace(os.Getenv("KERNEL_WAYLAND_PURE")), "true")
waylandEnabled := nestedWayland || pureWayland
if waylandEnabled {
waitForWayland(20 * time.Second)
} else {
// Wait for the X server. The wrapper starts chromium in parallel with
// xorg/xvfb, so the display socket may not be ready yet — without this
// gate chromium would fail on connect and supervisord would restart us.
if d := x11.WaitForDisplay(":1", 20*time.Second); d >= 20*time.Second {
fmt.Fprintf(os.Stderr, "warning: X display :1 not responsive after %s\n", d)
}

// Headful: wait for mutter to register before exec'ing chromium. If
// chromium maps its window with no WM present, the CSD hint it sends has
// no listener; mutter starts later, reparents the existing window, and
// applies default SSD — i.e., the titlebar with the close X. Headless
// has no WM, so skip.
if !*headless {
if d := x11.WaitForMutter(20 * time.Second); d >= 20*time.Second {
fmt.Fprintf(os.Stderr, "warning: mutter not registered after %s\n", d)
}
}
}

Expand All @@ -82,6 +92,9 @@ func main() {
}
final := chromiumflags.MergeFlagsWithRuntimeTokens(baseFlags, runtimeTokens)
final = withDefaultPrivateNetworkBypass(final)
if waylandEnabled {
final = withWaylandPlatform(final)
}

// Diagnostics for parity with previous scripts
fmt.Printf("BASE_FLAGS: %s\n", baseFlags)
Expand All @@ -107,12 +120,20 @@ func main() {
// recorder's sink; the root path below relies on this inherited env, while the
// non-root path re-asserts them in its runuser env allowlist.
env := os.Environ()
if !pureWayland {
env = append(env, "DISPLAY=:1")
}
env = append(env,
"DISPLAY=:1",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket",
"PULSE_SERVER="+pulseServer,
"PULSE_SINK="+pulseSink,
)
if waylandEnabled {
env = append(env,
"XDG_RUNTIME_DIR="+waylandRuntimeDir,
"WAYLAND_DISPLAY="+waylandDisplay,
)
}

if runAsRoot {
// Replace current process with Chromium
Expand Down Expand Up @@ -144,15 +165,23 @@ func main() {
// GetDefaultOutputDeviceID), which is the sink the recorder captures.
inner := []string{
"env",
"DISPLAY=:1",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket",
"PULSE_SERVER=" + pulseServer,
"PULSE_SINK=" + pulseSink,
"XDG_CONFIG_HOME=/home/kernel/.config",
"XDG_CACHE_HOME=/home/kernel/.cache",
"HOME=/home/kernel",
*chromiumPath,
}
if !pureWayland {
inner = append(inner, "DISPLAY=:1")
}
if waylandEnabled {
inner = append(inner,
"XDG_RUNTIME_DIR="+waylandRuntimeDir,
"WAYLAND_DISPLAY="+waylandDisplay,
)
}
inner = append(inner, *chromiumPath)
inner = append(inner, chromiumArgs...)
argv := append([]string{filepath.Base(runuserPath), "-u", "kernel", "--"}, inner...)
if err := syscall.Exec(runuserPath, argv, env); err != nil {
Expand All @@ -170,6 +199,37 @@ func withDefaultPrivateNetworkBypass(flags []string) []string {
return append(flags, defaultPrivateNetworkBypassFlag)
}

func withWaylandPlatform(flags []string) []string {
result := append([]string(nil), flags...)
for i, flag := range result {
if strings.HasPrefix(flag, "--ozone-platform=") {
result[i] = "--ozone-platform=wayland"
return result
}
if flag == "--ozone-platform" && i+1 < len(result) {
result[i+1] = "wayland"
return result
}
}
return append(result, "--ozone-platform=wayland")
}

func waitForWayland(timeout time.Duration) {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = waylandRuntimeDir
}
socket := filepath.Join(runtimeDir, waylandDisplay)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if fi, err := os.Stat(socket); err == nil && fi.Mode()&os.ModeSocket != 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
fmt.Fprintf(os.Stderr, "warning: Wayland display %s not ready after %s\n", socket, timeout)
}

// execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path.
func execLookPath(file string) (string, error) {
if strings.ContainsRune(file, os.PathSeparator) {
Expand Down
33 changes: 33 additions & 0 deletions server/cmd/chromium-launcher/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,39 @@ func TestDefaultPrivateNetworkBypassPreservesRuntimePrecedence(t *testing.T) {
}
}

func TestWithWaylandPlatform(t *testing.T) {
tests := []struct {
name string
flags []string
want []string
}{
{
name: "appends platform",
flags: []string{"--kiosk"},
want: []string{"--kiosk", "--ozone-platform=wayland"},
},
{
name: "replaces equals form",
flags: []string{"--ozone-platform=x11"},
want: []string{"--ozone-platform=wayland"},
},
{
name: "replaces separate form",
flags: []string{"--ozone-platform", "x11"},
want: []string{"--ozone-platform", "wayland"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := withWaylandPlatform(tt.flags)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("withWaylandPlatform() mismatch:\n got: %#v\nwant: %#v", got, tt.want)
}
})
}
}

func TestExecLookPath(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "mybin")
Expand Down
Loading
Loading