feat: resident guest init (#333)

Get this thing going..







<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=63134761"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>

The PR appears safe to merge; all previous findings are resolved and the
latest readiness change introduces no established actionable regression.

<h3>Summary</h3>

- Establishes required guest filesystems, runtime directories, device
permissions, and service processes.
- Reports initialization and service deaths over the lifecycle channel.
- Supports launch, restart, and shutdown commands for a resident guest.
- Separates service and workload identities and configures per-launch
runtime environments.
- Removes the currently inactive nescope screenshot option and makes
capture-chain verification fail explicitly when compositor readback is
unavailable.
- Reworks the guest image around `nesinit` as PID 1 without a
distribution service manager.

<h3>Diagram</h3>

```mermaid
sequenceDiagram
    participant Host
    participant Init as nesinit
    participant FS as Guest filesystems
    participant Services as Service stack
    participant Workload

    Init->>Host: Ready(protocol version)
    Host->>Init: Boot(mount descriptors)
    Init->>FS: Establish and mount shares
    Init->>Services: Spawn services in order
    Services-->>Init: Required sockets ready
    Init->>Host: Initialized(service names)
    Host->>Init: Launch(id, exec, on_exit)
    Init->>Workload: Spawn with isolated UID/runtime
    Init->>Host: Started(id)
    Workload-->>Init: Exit status
    Init->>Host: WorkloadExited(id, status)
    Host->>Init: Launch / Restart / Shutdown
```

<sub>Reviews (4) · Last reviewed commit: ["fix(nesinit): readiness is a
socket
that..."](731d34df9d)</sub>

<!-- /greptile_comment -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Ollikainen
2026-09-14 14:45:13 +03:00
committed by GitHub
parent ec8b13d0c9
commit 8246aa5538
43 changed files with 4448 additions and 1553 deletions

573
build/Containerfile Normal file
View File

@@ -0,0 +1,573 @@
# ═══════════════════════════════════════════════════════════
# nestri guest rootfs — the open half
#
# Builds a bootable Arch image containing Mesa (virtio-gpu native context)
# and the five open guest components: nesinit, nescope, neshub, neswire,
# nescapture. Two leaf targets, selected with `--target`:
#
# runtime_prod stripped, root locked (default: `make build`)
# runtime_debug debug tools, autologin root (`make build-debug`)
#
# There is no service manager, no init scripts and no udev. `nesinit` is PID 1
# and brings the box's services up from a table compiled into it, which is why
# this image is plain Arch rather than a distribution chosen for its init.
# ref(d-0064)
#
# Proton is here, and it is not a closed component: it is proton-cachyos built
# from source with --enable-wow64, which is what removes the need for a whole
# 32-bit host stack. Valve's steamclient.so is a different thing and is NOT
# here — that one is closed, and nestri/CLAUDE.md is explicit that nothing
# closed enters this repo. Whatever layers it on top of runtime_prod is a
# closed build outside this repo — see build/README.md.
#
# Build from the repo root, not from build/:
# docker build -f build/Containerfile --target runtime_prod -t nestri-guest .
# (`make build` in this directory does exactly that.)
# ═══════════════════════════════════════════════════════════
# Declared here and not beside the stage that uses it, because an ARG a FROM
# expands has to precede the *first* FROM in the file. Anywhere else it is
# scoped to one stage instead, `FROM ${PROTON_IMAGE}` expands to nothing, and
# the build fails with "no FROM statement found" — which says nothing about
# the actual mistake. See the Proton stage below for what this is.
ARG PROTON_IMAGE=ghcr.io/nestrilabs/proton-cachyos-native-wow64:11.0-20260703
# ───────────────────────────────────────────────────────────
# initial / builder
#
# The same distribution the guest is now, which it did not use to be: the
# guest was Artix, chosen for an init system this image no longer contains.
# Only build artifacts leave these stages.
# ───────────────────────────────────────────────────────────
FROM docker.io/archlinux:base-devel AS initial
RUN pacman -Syu --noconfirm
FROM initial AS builder
RUN pacman -S --noconfirm --needed \
cmake meson ninja git pkgconf \
python python-mako python-yaml python-packaging python-ply \
bison flex \
libpciaccess libepoxy libglvnd \
libx11 libxext libxrandr libxshmfence libxfixes libxxf86vm libxcb \
xcb-util-keysyms xorgproto \
wayland wayland-protocols \
expat zlib zstd libxml2 lm_sensors \
llvm clang libclc spirv-tools spirv-llvm-translator glslang \
elfutils libva libdrm directx-headers \
rust rust-bindgen cbindgen \
curl openssl \
pixman libxkbcommon \
vulkan-headers vulkan-icd-loader \
pipewire shaderc opus \
libinput \
&& pacman -Scc --noconfirm
WORKDIR /build
ENV ARTIFACTS=/artifacts
# ───────────────────────────────────────────────────────────
# Mesa — the only piece still fetched from outside this tree
# ───────────────────────────────────────────────────────────
FROM builder AS mesa-build
ARG MESA_GIT=https://gitlab.freedesktop.org/mesa/mesa.git
ARG MESA_COMMIT=b316485dd75ca6ab6c16c113480fb94c57d86c95
ARG JOBS=
RUN test -n "$JOBS" || JOBS=$(nproc) && \
git clone --depth=1 --revision="${MESA_COMMIT}" "${MESA_GIT}" /build/mesa-src && \
cd /build/mesa-src && \
meson setup builddir \
-Dprefix=/usr \
-Dbuildtype=release \
-Dplatforms=wayland \
-Dgallium-drivers=zink \
-Dvulkan-drivers=amd,intel \
-Damdgpu-virtio=true \
-Dintel-virtio-experimental=true \
-Dvideo-codecs=all \
-Degl=disabled \
-Dglx=disabled \
-Dgles1=disabled \
-Dgles2=disabled \
-Dgbm=disabled \
-Dgallium-va=disabled \
-Db_ndebug=true && \
ninja -C builddir -j${JOBS:-$(nproc)} && \
DESTDIR=/artifacts/mesa ninja -C builddir install && \
rm -rf /build/mesa-src && \
find /artifacts/mesa -type f -printf '/%P\n' > /artifacts/mesa/.manifest
# ───────────────────────────────────────────────────────────
# nestri workspace — same repo now, so this is COPY, not a private clone
#
# One `cargo build --release` over the guest members rather than one stage per
# binary:
# that per-repo splitting existed because nescope/neswire/nescapture/the hub
# were four separate private repos and a stage boundary was the only way to
# stop bumping one from invalidating the others' build cache. They are one
# Cargo workspace with one Cargo.lock now, so a BuildKit cache mount on
# target/ gives the same isolation — cargo's own incremental compiler
# already knows nescope changing does not touch nesprotocol's .rlib — without
# four copies of every shared dependency getting compiled once per stage.
#
# The members are named rather than `--workspace`, because the workspace holds
# one crate that is not part of a guest — `nesdoctor` runs on a stranger's own
# machine — and building it here would compile something this image will never
# contain. Naming them also means adding a member does not silently add a
# binary to the image.
# ───────────────────────────────────────────────────────────
FROM builder AS nestri-src
WORKDIR /build/nestri
COPY Cargo.toml Cargo.lock ./
COPY crates/nesprotocol crates/nesprotocol
# Not a guest component and not installed below — it runs on a stranger's own
# machine. It is here because `cargo` loads every workspace member's manifest
# before it builds anything, so a member missing from the context fails the
# build outright with `failed to read .../Cargo.toml`. Copying it costs a few
# files; the member list is the thing that decides, not this build.
COPY apps/nesdoctor apps/nesdoctor
COPY apps/nesinit apps/nesinit
COPY apps/nescope apps/nescope
COPY apps/neshub apps/neshub
COPY apps/neswire apps/neswire
COPY apps/nescapture apps/nescapture
FROM nestri-src AS nestri-build
RUN --mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/build/nestri/target \
cargo build --release \
-p nesinit -p nescope -p neshub -p neswire -p nescapture && \
mkdir -p /artifacts/nestri/usr/bin /artifacts/nestri/usr/lib \
/artifacts/nestri/usr/share/vulkan/implicit_layer.d && \
install -Dm755 target/release/nesinit /artifacts/nestri/usr/bin/nesinit && \
install -Dm755 target/release/nescope /artifacts/nestri/usr/bin/nescope && \
install -Dm755 target/release/neshub /artifacts/nestri/usr/bin/neshub && \
install -Dm755 target/release/neswire /artifacts/nestri/usr/bin/neswire && \
install -Dm755 target/release/libnescapture_layer.so \
/artifacts/nestri/usr/lib/libnescapture_layer.so && \
install -Dm644 apps/nescapture/manifest/VK_LAYER_nescapture.json \
/artifacts/nestri/usr/share/vulkan/implicit_layer.d/VK_LAYER_nescapture.json && \
find /artifacts/nestri -type f -printf '/%P\n' > /artifacts/nestri/.manifest
# ───────────────────────────────────────────────────────────
# Proton — pulled, not built here
#
# Building it takes hours and it changes only when its own tag moves, so it
# has a cadence of its own and an image of its own. The published image is
# `FROM scratch` over the tree, so its root *is* the tree and there is nothing
# in it to run — only something to copy from.
#
# Built with `--enable-wow64`, which is the whole reason it is a build of ours
# rather than the distribution's package. wow64 runs 32-bit Windows code
# inside a 64-bit unix process, so a box needs no lib32 anything: no 32-bit
# glibc, no second Mesa for i686, and — the one that matters most here — no
# second capture layer, because the game is a 64-bit process and loads the
# 64-bit Vulkan loader the existing layer already sits in. The distribution's
# package is built without the flag, which is exactly why it depends on
# lib32-*.
#
# Override to build it yourself; the tag is a version and moves deliberately.
# ───────────────────────────────────────────────────────────
FROM ${PROTON_IMAGE} AS proton
# ───────────────────────────────────────────────────────────
# os-base — the Arch rootfs itself
#
# `FROM archlinux:base` directly, and `pacman -S` as plain RUN steps — not a
# privileged host `chroot` into a hand-extracted tarball, which would need
# /proc, /sys and /dev bind-mounted in first (they don't exist inside a chroot
# target until something puts them there). A Containerfile RUN step already
# executes inside a real container with its own /proc, /sys, /dev, so there is
# no bind-mount step to write at all.
#
# # systemd goes; systemd-libs stays
#
# Nothing in the package list below depends on `systemd`, and two things in it
# — dbus-daemon and wireplumber — link `libsystemd.so.0`, which comes from the
# separate `systemd-libs` package. So the removal is `-Rdd` of `systemd` and
# `systemd-sysvcompat` only, which is normal rather than a compromise: keeping
# the library while having no PID 1 from it is exactly how a distribution
# without systemd ships these same programs.
#
# Removing the package also removes its pacman hooks, which is the point. The
# hooks call `systemd-tmpfiles`, `systemd-sysusers` and `udevadm`; leaving them
# behind while deleting what they call is how a later transaction fails
# obscurely, and a previous attempt at this image lost two services to exactly
# that.
# ───────────────────────────────────────────────────────────
FROM docker.io/archlinux:base AS os-base
# The base image ships an unsigned local keyring, so upgrading
# `archlinux-keyring` runs a hook that reports `There is no secret key
# available to sign with` and then `error: command failed to execute
# correctly`. It is cosmetic and it is also every Arch container's build log.
# One line fixes it, and it is worth the seconds: a build that always prints an
# error is a build nobody reads an error out of.
RUN pacman-key --init
# Installed first and removed second, so every dependency resolves normally
# before anything is taken out from under it.
#
# Gone with the init system: `openrc`, `udev`, `dbus-openrc`. `udev` is not
# replaced by anything — `devtmpfs` creates the nodes and init sets the two
# modes that matter, because the compositor takes input through Wayland and
# opens nothing udev provides. ref(d-0064)
#
# `logrotate` is also gone, and that one is not about the init system: a box
# keeps no logs to rotate. What is worth reading leaves over the control
# channel, and `/var/log` is a small tmpfs that is discarded with the box.
# `mesa` is not in this list, and the two `--assume-installed` flags are why.
#
# The distribution's Mesa used to be installed so that every runtime dependency
# of *a* Mesa was present and correctly versioned, and ours was then overlaid
# on top. That worked for the unversioned filenames and not for the versioned
# one: `libgallium-<version>.so` from the package sat beside ours, 53 MB of it,
# referenced by nothing. Installing it to overwrite most of it was always the
# roundabout way round; telling pacman the dependency is already satisfied is
# the direct one.
#
# Exactly two flags are needed and both were checked by dropping each in turn:
# `mesa` is the name two packages depend on, and `opengl-driver` is a virtual
# provide `libglvnd` requires that only a real driver package satisfies. The
# other three names Mesa provides — `mesa-libgl`, `libva-driver`,
# `libva-mesa-driver` — change nothing here, so they are not listed.
#
# What makes this safe is that our Mesa is a superset for this image's
# purposes: it builds the drivers a box can actually use and the package's
# other ones (apple, asahi, armada, d3d12) are for hardware no box has. What it
# does *not* build is a software rasteriser, so there is no llvmpipe fallback —
# a box with no working GPU path now fails instead of rendering slowly, which
# is the honest outcome for something that exists to stream frames.
#
# Two packages below are explicit *because* Mesa is gone, and both used to
# arrive as its dependencies: `llvm-libs`, which the radeonsi driver links for
# shader compilation, and `lm_sensors`, which it links for `libsensors.so.5`.
# The second was found by the check further down rather than by reading the
# list — dropping a package takes its dependency tree with it, and the part of
# that tree something else was quietly using is not visible from here.
RUN pacman -Syu --noconfirm --needed \
--assume-installed mesa --assume-installed opengl-driver \
dbus \
iptables iproute2 \
libglvnd libdrm libepoxy libxxf86vm libinput wayland \
expat zlib llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \
pixman libxkbcommon xcb-util-keysyms xorg-xwayland \
pipewire pipewire-audio wireplumber opus \
python libunwind \
&& rm -f /usr/share/libalpm/hooks/dbus-reload.hook \
&& pacman -Rdd --noconfirm systemd systemd-sysvcompat \
&& pacman -Scc --noconfirm
# `libunwind` is Wine's, not ours. `ntdll.so` links it, so without it every
# process Wine starts dies at `could not load ntdll.so`, which is the first
# thing it loads and reads like Wine itself being broken. Found 2026-09-12,
# after the prefix had already been created -- so the session got past every
# check that Proton was present and usable.
#
# `python` is not a build dependency here -- the builder stage has its own for
# Mesa -- it is a *runtime* one. The compatibility tool's entry point is a
# Python script, so a box without an interpreter starts a game and the launch
# ends with `env: 'python3': No such file or directory` and an exit status that
# reads like an ordinary finish. Found 2026-09-12, on the first session that
# got as far as launching one.
# `dbus-reload.hook` is deleted above, before the removal rather than after,
# and it is the whole reason that line is there: the hook runs
# `/usr/share/libalpm/scripts/systemd-hook`, which systemd owns, so the
# transaction that removes systemd trips its own leftover on the way out —
# `call to execv failed`, then `error: command failed to execute correctly`.
# pacman treats a post-transaction hook failure as non-fatal, so the build
# survives it and the image is fine; what it leaves is an error message in
# every future transaction and a reader with no way to tell it from a real
# one. Removing the hook first means the error never happens.
# Nothing left may point at a program that is not here.
#
# The specific case above is fixed; this is the general one, and it exists
# because a hook calling a deleted binary is the exact shape of the failure
# that took two services off a previous version of this image. A build error
# is a much better place to find the next one than a log.
RUN for hook in /usr/share/libalpm/hooks/*.hook; do \
exec_line="$(awk -F'= *' '/^Exec/ { print $2; exit }' "$hook")"; \
program="${exec_line%% *}"; \
case "$program" in /*) ;; *) continue ;; esac; \
test -e "$program" \
|| { echo "$(basename "$hook") runs $program, which is not in the image" >&2; exit 1; }; \
done
# The check, because the removal above is the kind of thing a later `pacman
# -Syu` undoes quietly. A box with systemd's PID 1 back in it boots something
# other than `nesinit`, and the symptom is a guest that never dials out.
RUN test ! -e /usr/lib/systemd/systemd \
|| { echo "systemd's PID 1 is back in the image" >&2; exit 1; }
# `groupadd -f`, because some of these already exist in the base image and the
# rest have to. Nothing creates them at runtime any more: udev's rules did that
# for device nodes, and with udev gone init sets the two modes that matter
# directly. ref(d-0064)
#
# **Two users, and they must stay two.** `nestri` runs the services that come
# with this image; `nesplay` is who a workload runs as. Sharing one user between
# them is what lets workload code impersonate a service — it can replace the
# socket a service listens on and answer in its place, and the answer that
# matters is the address a client is told to connect to. Init refuses an address
# served by the workload's own user, so a single shared user does not merely
# weaken that check, it makes every session fail it.
#
# The uid a workload actually runs as is chosen by whoever asks for the box, not
# here; this account exists so that the number has a home, a shell and a name in
# `ps`, and so the separation has somewhere to be written down.
RUN groupadd -f audio && groupadd -f video && groupadd -f input && groupadd -f render && \
useradd -m -u 1000 -s /bin/bash nestri && \
for g in audio video input render; do gpasswd -a nestri "$g" >/dev/null; done && \
useradd -m -u 1001 -s /bin/bash nesplay && \
for g in audio video input render; do gpasswd -a nesplay "$g" >/dev/null; done
# ───────────────────────────────────────────────────────────
# runtime — everything common to debug and prod
# ───────────────────────────────────────────────────────────
FROM os-base AS runtime
# This is what GHCR actually uses to connect a pushed image back to its
# repo — not a setting to toggle after the fact, a label the image has to
# carry. Without it a manually-pushed image shows no "used by" repo on its
# package page even though this Containerfile is exactly what built it.
LABEL org.opencontainers.image.source="https://github.com/nestrilabs/nestri"
# Our own builds, overlaid on the distro's mesa. The distro package landed
# first (above) so every runtime dependency of *a* Mesa is present and
# correctly versioned; this overwrites its .so files with ours.
#
# COPY --from runs as root inside this build with no invoking-user uid to
# stamp onto / or /usr/bin, unlike a host-side `podman cp` + `cp -a` — so
# there is no ownership-sanity-check to write here. Nothing to catch, on
# purpose, not an oversight.
COPY --from=mesa-build /artifacts/mesa/.manifest /tmp/mesa.manifest
COPY --from=nestri-build /artifacts/nestri/.manifest /tmp/nestri.manifest
RUN cat /tmp/mesa.manifest /tmp/nestri.manifest > /tmp/.strip-manifest && \
rm -f /tmp/mesa.manifest /tmp/nestri.manifest
COPY --from=mesa-build /artifacts/mesa /
COPY --from=nestri-build /artifacts/nestri /
# The Proton tree, whose image root is the tree, so this lands it at
# /usr/share/steam/compatibilitytools.d/proton-cachyos.
#
# Deliberately not in the strip manifest above: that list is our own build
# output, and the two stripping decisions are not the same one. Proton ships
# a Windows toolchain's worth of PE binaries that `strip` has no business
# touching, and its unix side is already built the way its own packaging
# builds it.
COPY --from=proton / /
RUN ldconfig
COPY build/etc/ /etc/
# `nesinit` is PID 1, and `/usr/bin/init` is the fallback for a kernel started
# without an explicit `init=`. `systemd-sysvcompat` used to own that path and
# was removed with the rest of systemd, so nothing else claims it.
RUN ln -sf nesinit /usr/bin/init
# One id per boot, not one per image.
#
# `dbus-uuidgen --ensure=/etc/machine-id` used to run here, which baked one id
# into the image and made every box built from it the same machine. Init writes
# a fresh one to /run at boot instead, so both of these are symlinks into a
# tmpfs — which is also the only place they could be, with a read-only root.
RUN rm -f /etc/machine-id /var/lib/dbus/machine-id && \
mkdir -p /var/lib/dbus && \
ln -sf /run/machine-id /etc/machine-id && \
ln -sf /run/machine-id /var/lib/dbus/machine-id
# Session mount points. The guest root is read-only at runtime, so a runtime
# mkdir gets EROFS and takes a service down before it starts — these have to
# already exist in the image.
#
# `/nestri/logs` is a mount point and nothing mounts it from in here any more:
# a share arrives because the caller named it in the boot descriptor, which is
# the same rule every other share follows. The directory stays so that naming
# it works.
RUN mkdir -p /nestri/install /nestri/user /nestri/work /nestri/game /nestri/logs && \
chmod 0755 /nestri /nestri/install /nestri/user /nestri/work /nestri/game /nestri/logs && \
mkdir -p /dev/shm && chmod 1777 /dev/shm && \
mkdir -p /run/user/1000 /var/log && \
# The distribution's own empty `fstab` goes with ours. Nothing in a box
# reads either: init mounts what a box always needs, and every share comes
# from the boot descriptor. A file that looks like it configures mounts and
# is read by nothing is a file somebody edits expecting an effect.
rm -f /etc/network/interfaces /etc/inittab /etc/fstab
# Nothing in this image may be an init system except `nesinit`.
#
# A service manager arriving as a dependency of something innocuous is the
# failure this catches, and it is silent otherwise: the extra init does not run
# — the kernel is told which one to start — it just sits there with its own
# ideas about what the box should be doing, waiting for somebody to wire it in.
RUN for intruder in /usr/lib/systemd/systemd /sbin/openrc-init /usr/bin/openrc-init \
/sbin/runit-init /usr/bin/runit-init /sbin/dinit /usr/bin/dinit; do \
test ! -e "$intruder" || { echo "a second init is in the image: $intruder" >&2; exit 1; }; \
done
# What `nesinit` will look for at runtime, checked while there is somebody to
# read the failure.
#
# It is a table compiled into a binary, so a missing program is not a build
# error — it is a service that does not come up in a box somebody is waiting
# on, reported over the control channel and read hours later. Checking here
# turns that into a failed build.
# Everything this image promises must resolve the libraries it links.
#
# This is the check the Mesa change needs: dropping a package that provided
# libraries is how a binary ends up resolving nothing, and the symptom is not a
# build failure — it is a service that will not start in a box somebody is
# waiting on, or a render path that is missing rather than slow. It caught
# exactly that on the first run, and the missing library was two levels down a
# dependency tree nobody had reason to read.
#
# **Named rather than swept, and that is deliberate.** A sweep over everything
# in /usr/lib fails on a stock image: a distribution ships optional plugins
# whose optional dependencies are not installed — pinentry's Qt build, mpg123's
# JACK output, libdecor's GTK backend — and every one of those was already
# unresolved before this stage existed. A check that reports a dozen things
# nobody intends to load is a check the next person deletes. This list is what
# the image is *for*: the components, the services init starts, the chain
# between a workload and the GPU, and Wine's own core.
#
# **Wine was added after it was missed**, and then narrowed twice, which is
# worth recording so nobody widens it again.
#
# It was missed because the list covered everything this image ships *of ours*
# and nothing of the compatibility tool's, so an unresolved `libunwind.so.8`
# behind `ntdll.so` survived a build whose whole purpose is catching that, and
# surfaced as a session that created a prefix and could not start one process
# in it.
#
# The obvious fix -- sweep every `*-unix/*.so` -- is wrong in both directions.
# It is noisy: those objects are Wine's optional backends, and their
# dependencies are a camera library, a media stack, a VR loader, a smartcard
# daemon and OpenCL, none of which belong in a box. And it cannot see what it
# is checking: Wine's unix objects **link each other by soname** and are
# resolved by Wine's own loader rather than by `ld.so`, so `ldd` reports
# `ntdll.so` and `win32u.so` themselves as missing while they sit in the same
# directory. Forty files, every one a false positive, hiding the one real
# entry.
#
# So: the programs in `bin/`, which are ordinary ELF and resolve normally, and
# `ntdll.so`, which is the first thing Wine loads and the one that linked the
# missing library. That is exactly the failure that got through, with none of
# the noise. The Windows-side DLLs beside them are not ELF and `ldd` skips them
# anyway.
# The output is one file per line with its own missing libraries under it, and
# then every missing library once at the end. That last list is what somebody
# acts on -- it is the set of packages to add -- and forty files each naming the
# same two libraries is not that list. An earlier version printed one
# comma-joined line and cut the wrong field out of `ldd`, so it named no
# libraries at all: `ldd` indents with a tab, which `tr -s ' '` does not
# collapse, so the second space-separated field is `=>`.
RUN failed=0; \
: > /tmp/missing-libs; \
for f in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/lib/libnescapture_layer.so \
/usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/wireplumber /usr/bin/ip \
/usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \
/usr/lib/libvulkan_*.so /usr/lib/dri/*.so /usr/lib/gbm/*.so \
/usr/share/steam/compatibilitytools.d/proton-cachyos/files/bin/* \
/usr/share/steam/compatibilitytools.d/proton-cachyos/files/lib*/wine/*-unix/ntdll.so; do \
[ -e "$f" ] || continue; \
libs="$(ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \
[ -n "$libs" ] || continue; \
failed=1; \
printf ' %s\n' "$f" >&2; \
printf ' %s\n' $libs >&2; \
printf '%s\n' $libs >> /tmp/missing-libs; \
done; \
if [ "$failed" != 0 ]; then \
echo "" >&2; \
echo " every library above, once each -- this is the list to install:" >&2; \
sort -u /tmp/missing-libs | sed 's/^/ /' >&2; \
exit 1; \
fi
RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/wireplumber /usr/bin/ip \
/usr/bin/python3 \
/usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \
test -x "$required" || { echo "the image is missing $required" >&2; exit 1; }; \
done
# An entry point that is executable is not an entry point that runs.
#
# The check above passed on an image whose compatibility tool was a Python
# script with no interpreter behind it: `test -x` says the file may be
# executed, and the kernel then fails to find what the shebang names. A session
# got as far as launching a game and ended with
# `env: 'python3': No such file or directory`.
#
# So every script this image promises resolves its own interpreter. `env` is
# unwrapped where it is used, because a shebang of `#!/usr/bin/env python3`
# names `env` and the thing that is actually missing is the argument.
RUN for script in /usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \
head -c 2 "$script" | grep -q '#!' || continue; \
shebang="$(head -1 "$script" | sed 's/^#!//')"; \
interpreter="${shebang%% *}"; \
case "$interpreter" in \
*/env) argument="${shebang#* }"; interpreter="$(command -v "${argument%% *}" || true)";; \
esac; \
test -n "$interpreter" && test -x "$interpreter" \
|| { echo "$script needs an interpreter the image does not have: $shebang" >&2; exit 1; }; \
done
# ───────────────────────────────────────────────────────────
# runtime_prod — the default: `make build`
# ───────────────────────────────────────────────────────────
FROM runtime AS runtime_prod
# No console is offered by either flavour: `nesinit` spawns no getty, because
# the way into a guest that will not boot is `init=/bin/bash` on the kernel
# command line, which needs nothing from the image but a shell. So the locked
# root account is belt and braces rather than the only thing standing between
# a box and a login prompt.
RUN passwd -l root
RUN while IFS= read -r f; do \
[ -f "$f" ] && strip --strip-unneeded "$f" 2>/dev/null || true; \
done < /tmp/.strip-manifest; \
rm -rf /tmp/.strip-manifest /var/cache/pacman/pkg/* /tmp/* /root/.cache \
/usr/share/man /usr/share/doc /usr/share/locale \
/usr/lib/cmake /usr/lib/pkgconfig /usr/share/pkgconfig /usr/include \
/usr/share/gir-1.0 /usr/lib/udev; \
find /usr/lib -name '*.a' -delete
RUN echo "NESTRI_STAGE=runtime_prod" >> /etc/os-release
# ───────────────────────────────────────────────────────────
# runtime_debug — `make build-debug`
# ───────────────────────────────────────────────────────────
FROM runtime AS runtime_debug
# `-Sy` and not `-Syu`: a full upgrade here can pull a package back in as
# somebody's dependency, and the one that matters is systemd. The check below
# catches it either way, but a debug image that fails to build is worse than
# one that is a few days behind on versions it only uses for `vulkaninfo`.
RUN pacman -Sy --noconfirm --needed vulkan-tools mesa-utils libva-utils && \
pacman -Scc --noconfirm
# The same guard as the runtime stage, because the transaction above is exactly
# the kind that quietly reinstates an init system.
RUN test ! -e /usr/lib/systemd/systemd \
|| { echo "systemd's PID 1 came back with the debug tools" >&2; exit 1; }
# Root has a password here and nothing offers a login prompt to type it into.
# It is for `su` from an `init=/bin/bash` shell, which is the whole debug route.
RUN echo 'root:nestri' | chpasswd
RUN echo "NESTRI_STAGE=runtime_debug" >> /etc/os-release

View File

@@ -0,0 +1,40 @@
# The guest rootfs build's context.
#
# The name is load-bearing and it is not `.containerignore`. Podman looks for
# an ignore file *adjacent to the Containerfile and named after it* — here,
# `Containerfile.containerignore` — before falling back to one at the root of
# the build context. The context is the repository root, so a bare
# `build/.containerignore` sits in neither place and is silently read by
# nothing: the build still works, it just sends the whole tree.
#
# Docker looks for the `.dockerignore` suffix only, so a docker build reads the
# repository-root file instead of this one and sends more than it needs. That
# is the cost of the container-agnostic name and it is only a cost in bytes.
#
# This file *replaces* the repository-wide ignore file rather than adding to
# it, so the first block below is that file repeated. The second is what only
# this build excludes.
#
# This build's context is the repository root (see `Makefile`), and it COPYs
# the workspace manifests plus the Rust members and nothing else. The
# TypeScript half is therefore dead weight in the context — a few megabytes
# sent to the daemon versus the whole tree.
.git
node_modules
target
build/output
.env
.env.*
.wrangler
dist
.output
docs
apps/api
apps/auth
packages
*.md
deno.lock
bun.lock
.zed
.github

102
build/Containerfile.proton Normal file
View File

@@ -0,0 +1,102 @@
# ═══════════════════════════════════════════════════════════
# proton-cachyos, built wow64-only
#
# Separate from the guest Containerfile because it is a separate cadence:
# hours to build, and only when PROTON_TAG moves. The guest image pulls the
# result from a registry instead of rebuilding it, which is why this file is
# not part of that build and is not reached by `make build`.
#
# The final stage is FROM scratch, so the image *is* the Proton tree — nothing
# to run, only something to COPY --from.
#
# Build and publish with `make proton-image` / `make proton-push`. **Its
# context is this directory**, not the repository root the guest build uses:
# all it needs is the two scripts beside it, and a context of the whole tree
# would hand it a multi-gigabyte `output/` for no reason.
#
# `PROTON_TAG` is the one thing to change, and the Makefile derives the
# published image's version from it. They are the same number in two
# spellings, and an image whose name does not say which Proton is inside it
# is worse than no image.
# ═══════════════════════════════════════════════════════════
FROM docker.io/archlinux:base-devel AS builder
# proton-cachyos-native's makedepends, minus every lib32-* (that is the whole
# point of --enable-wow64), with two substitutions Arch requires: ocl-icd
# provides opencl-icd-loader, and mesa-libgl is folded into libglvnd.
#
# glib2-devel is the one that is easy to miss — it carries glib-mkenums, which
# libsoup's meson looks up through glib-2.0's pkg-config variables and fails on
# obscurely. unzip and zip are for the xalia dist step. afdko is not packaged at
# all, and the fonts submodule needs it, so it comes from PyPI.
RUN pacman -Syu --noconfirm --needed \
alsa-lib clang cmake curl ffmpeg fontforge giflib git glib2-devel \
glslang gnutls gtk3 libgphoto2 libglvnd libpulse libva libxcomposite \
libxinerama libxxf86vm lld mesa meson ninja nasm \
opencl-headers ocl-icd pcsclite perl perl-json python python-pip \
python-pefile python-setuptools-scm rsync rust samba unixodbc \
unzip zip v4l-utils vulkan-headers vulkan-icd-loader wayland \
wayland-protocols wget xorg-util-macros \
mingw-w64-gcc mingw-w64-binutils mingw-w64-crt mingw-w64-headers \
mingw-w64-winpthreads \
&& pip install --break-system-packages --no-cache-dir afdko \
&& pacman -Scc --noconfirm
# widl generates vkd3d's public headers. Without it autoconf sets HAVE_WIDL to
# false, the headers are silently not generated, and the build dies an hour
# later on a missing vkd3d_d3dx9shader.h. Arch ships widl only inside `wine`,
# which requires multilib; the AUR's mingw-w64-tools builds it standalone, so do
# the same.
#
# The digest is not a formality. What is extracted here has its `configure` and
# its makefiles run as root in this builder, and what they produce is copied
# into the image a box runs -- so whoever can change these bytes can change what
# runs on every host. SourceForge hands the request to whichever mirror it
# likes, over a connection this builder does not pin, and the project publishes
# no signature. The digest is the only thing that makes the mirror not matter.
#
# Taken 2026-09-14 from two different mirrors of v14.0.0, which agreed. A
# mismatch here is not a thing to paper over by taking the new digest: it means
# the bytes behind this exact version string changed, and that wants looking at
# before it wants fixing.
ARG MINGW_W64_VER=14.0.0
ARG MINGW_W64_SHA256=6eaf921d9eb987d3820b364ea9775bc19b965ec81490b6fdd716526c28e1995c
RUN curl -fL --retry 3 -o /tmp/mingw-w64.tar.bz2 \
"https://sourceforge.net/projects/mingw-w64/files/mingw-w64/mingw-w64-release/mingw-w64-v${MINGW_W64_VER}.tar.bz2/download" \
&& echo "${MINGW_W64_SHA256} /tmp/mingw-w64.tar.bz2" | sha256sum -c - \
&& tar xf /tmp/mingw-w64.tar.bz2 -C /tmp \
&& for arch in i686-w64-mingw32 x86_64-w64-mingw32; do \
mkdir -p "/tmp/widl-${arch}" && cd "/tmp/widl-${arch}" \
&& "/tmp/mingw-w64-v${MINGW_W64_VER}/mingw-w64-tools/widl/configure" \
--prefix=/usr --target="${arch}" --program-prefix="${arch}-" \
&& make -j"$(nproc)" && make install; \
done \
&& rm -rf /tmp/mingw-w64* /tmp/widl-*
WORKDIR /build
ARG PROTON_GIT=https://github.com/CachyOS/proton-cachyos.git
ARG PROTON_TAG=cachyos-11.0-20260703-native
ARG GECKO_VER=2.47.4
ARG MONO_VER=11.2.0
ARG XALIA_VER=0.4.9
ARG JOBS=
# Fetch and build are separate layers on purpose: the submodule checkout runs
# well past ten minutes, and a build that fails on a flag or a missing tool must
# not pay for it again.
COPY scripts/proton-fetch.sh /build/proton-fetch.sh
RUN PROTON_GIT="${PROTON_GIT}" PROTON_TAG="${PROTON_TAG}" \
GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" XALIA_VER="${XALIA_VER}" \
bash /build/proton-fetch.sh
COPY scripts/proton-build.sh /build/proton-build.sh
RUN GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" JOBS="${JOBS}" \
bash /build/proton-build.sh
# ═══════════════════════════════════════════════════════════
# The publishable artifact: the Proton tree and nothing else
# ═══════════════════════════════════════════════════════════
FROM scratch
COPY --from=builder /artifacts/proton/ /

View File

@@ -0,0 +1,15 @@
# This build's context is `build/`, not the repository root, because all it
# needs is the two scripts beside the Containerfile.
#
# `output/` is the reason this file exists. It holds packed rootfs images —
# multiple gigabytes each — and a build context is copied before the first
# instruction runs, so without this line every Proton build starts by moving
# the last one it produced.
output
# Nothing else here is an input to this build.
etc
README.md
Makefile
Containerfile
Containerfile.containerignore

View File

@@ -1,309 +0,0 @@
# ═══════════════════════════════════════════════════════════
# nestri guest rootfs — the open half
#
# Builds a bootable Artix/OpenRC image containing Mesa (virtio-gpu native
# context) and the four open guest components: nescope, neshub, neswire,
# nescapture. Two leaf targets, selected with `--target`:
#
# runtime_prod stripped, root locked (default: `make build`)
# runtime_debug debug tools, autologin root (`make build-debug`)
#
# What is deliberately NOT here: Proton, Valve's steamclient.so, or anything
# else closed. nestri/CLAUDE.md is explicit that nothing closed enters this
# repo. Whatever layers those on top of runtime_prod is a closed build
# outside this repo — see build/README.md.
#
# Build from the repo root, not from build/:
# docker build -f build/Dockerfile --target runtime_prod -t nestri-guest .
# (`make build` in this directory does exactly that.)
# ═══════════════════════════════════════════════════════════
# ───────────────────────────────────────────────────────────
# initial / builder — Arch, not Artix
#
# The guest is Artix, but Artix's repos are Arch-derived and the toolchain/
# glibc generation is the same, while the archlinux image is the
# better-maintained of the two for actually compiling things. Only build
# artifacts leave these stages.
# ───────────────────────────────────────────────────────────
FROM docker.io/archlinux:base-devel AS initial
RUN pacman -Syu --noconfirm
FROM initial AS builder
RUN pacman -S --noconfirm --needed \
cmake meson ninja git pkgconf \
python python-mako python-yaml python-packaging python-ply \
bison flex \
libpciaccess libepoxy libglvnd \
libx11 libxext libxrandr libxshmfence libxfixes libxxf86vm libxcb \
xcb-util-keysyms xorgproto \
wayland wayland-protocols \
expat zlib zstd libxml2 lm_sensors \
llvm clang libclc spirv-tools spirv-llvm-translator glslang \
elfutils libva libdrm directx-headers \
rust rust-bindgen cbindgen \
curl openssl \
pixman libxkbcommon \
vulkan-headers vulkan-icd-loader \
pipewire shaderc opus \
libinput \
&& pacman -Scc --noconfirm
WORKDIR /build
ENV ARTIFACTS=/artifacts
# ───────────────────────────────────────────────────────────
# Mesa — the only piece still fetched from outside this tree
# ───────────────────────────────────────────────────────────
FROM builder AS mesa-build
ARG MESA_GIT=https://gitlab.freedesktop.org/mesa/mesa.git
ARG MESA_COMMIT=b78fc73dd898a7dfa87448a4b5ff4459a870e21a
RUN git clone --depth=1 --revision="${MESA_COMMIT}" "${MESA_GIT}" /build/mesa-src && \
cd /build/mesa-src && \
meson setup builddir \
-Dprefix=/usr \
-Dbuildtype=release \
-Dplatforms=wayland,x11 \
-Dgallium-drivers=zink,radeonsi,iris \
-Dvulkan-drivers=amd,intel \
-Damdgpu-virtio=true \
-Dintel-virtio-experimental=true \
-Dvideo-codecs=all \
-Degl=enabled \
-Dglx=dri \
-Dgles1=enabled \
-Dgles2=enabled \
-Dgbm=enabled \
-Dgallium-va=enabled \
-Db_ndebug=true && \
ninja -C builddir && \
DESTDIR=/artifacts/mesa ninja -C builddir install && \
rm -rf /build/mesa-src && \
find /artifacts/mesa -type f -printf '/%P\n' > /artifacts/mesa/.manifest
# ───────────────────────────────────────────────────────────
# nestri workspace — same repo now, so this is COPY, not a private clone
#
# One `cargo build --release --workspace` rather than one stage per binary:
# that per-repo splitting existed because nescope/neswire/nescapture/the hub
# were four separate private repos and a stage boundary was the only way to
# stop bumping one from invalidating the others' build cache. They are one
# Cargo workspace with one Cargo.lock now, so a BuildKit cache mount on
# target/ gives the same isolation — cargo's own incremental compiler
# already knows nescope changing does not touch nesprotocol's .rlib — without
# four copies of every shared dependency getting compiled once per stage.
# ───────────────────────────────────────────────────────────
FROM builder AS nestri-src
WORKDIR /build/nestri
COPY Cargo.toml Cargo.lock ./
COPY crates/nesprotocol crates/nesprotocol
COPY apps/nescope apps/nescope
COPY apps/neshub apps/neshub
COPY apps/neswire apps/neswire
COPY apps/nescapture apps/nescapture
FROM nestri-src AS nestri-build
RUN --mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/build/nestri/target \
cargo build --release --workspace && \
mkdir -p /artifacts/nestri/usr/bin /artifacts/nestri/usr/lib \
/artifacts/nestri/usr/share/vulkan/implicit_layer.d && \
install -Dm755 target/release/nescope /artifacts/nestri/usr/bin/nescope && \
install -Dm755 target/release/neshub /artifacts/nestri/usr/bin/neshub && \
install -Dm755 target/release/neswire /artifacts/nestri/usr/bin/neswire && \
install -Dm755 target/release/libnescapture_layer.so \
/artifacts/nestri/usr/lib/libnescapture_layer.so && \
install -Dm644 apps/nescapture/manifest/VK_LAYER_nescapture.json \
/artifacts/nestri/usr/share/vulkan/implicit_layer.d/VK_LAYER_nescapture.json && \
find /artifacts/nestri -type f -printf '/%P\n' > /artifacts/nestri/.manifest
# ───────────────────────────────────────────────────────────
# os-base — the Artix rootfs itself
#
# `FROM artixlinux/artixlinux:base-openrc` directly, and `pacman -S` as plain
# RUN steps — not a privileged host `chroot` into a hand-extracted tarball,
# which would need /proc, /sys and /dev bind-mounted in first (they don't
# exist inside a chroot target until something puts them there). A
# Dockerfile RUN step already executes inside a real container with its own
# /proc, /sys, /dev, so there is no bind-mount step to write at all.
# ───────────────────────────────────────────────────────────
FROM docker.io/artixlinux/artixlinux:base-openrc AS os-base
RUN pacman -Syu --noconfirm --needed \
base openrc udev dbus dbus-openrc \
iptables iproute2 \
mesa libglvnd libdrm libepoxy libxxf86vm libinput wayland \
expat zlib llvm-libs elfutils libva shaderc vulkan-icd-loader \
pixman libxkbcommon xcb-util-keysyms xorg-xwayland \
pipewire pipewire-audio wireplumber dbus logrotate opus \
&& pacman -Scc --noconfirm
# groupadd -f so this is idempotent whether or not udev's rules already
# created these.
#
# **Two users, and they must stay two.** `nestri` runs the services that come
# with this image; `nesplay` is who a workload runs as. Sharing one user between
# them is what lets workload code impersonate a service — it can replace the
# socket a service listens on and answer in its place, and the answer that
# matters is the address a client is told to connect to. Init refuses an address
# served by the workload's own user, so a single shared user does not merely
# weaken that check, it makes every session fail it.
#
# The uid a workload actually runs as is chosen by whoever asks for the box, not
# here; this account exists so that the number has a home, a shell and a name in
# `ps`, and so the separation has somewhere to be written down.
RUN groupadd -f audio && groupadd -f video && groupadd -f input && groupadd -f render && \
useradd -m -u 1000 -s /bin/bash nestri && \
for g in audio video input render; do gpasswd -a nestri "$g" >/dev/null; done && \
useradd -m -u 1001 -s /bin/bash nesplay && \
for g in audio video input render; do gpasswd -a nesplay "$g" >/dev/null; done
# ───────────────────────────────────────────────────────────
# runtime — everything common to debug and prod
# ───────────────────────────────────────────────────────────
FROM os-base AS runtime
# This is what GHCR actually uses to connect a pushed image back to its
# repo — not a setting to toggle after the fact, a label the image has to
# carry. Without it a manually-pushed image shows no "used by" repo on its
# package page even though this Dockerfile is exactly what built it.
LABEL org.opencontainers.image.source="https://github.com/nestrilabs/nestri"
# Our own builds, overlaid on the distro's mesa. The distro package landed
# first (above) so every runtime dependency of *a* Mesa is present and
# correctly versioned; this overwrites its .so files with ours.
#
# COPY --from runs as root inside this build with no invoking-user uid to
# stamp onto / or /usr/bin, unlike a host-side `podman cp` + `cp -a` — so
# there is no ownership-sanity-check to write here. Nothing to catch, on
# purpose, not an oversight.
COPY --from=mesa-build /artifacts/mesa/.manifest /tmp/mesa.manifest
COPY --from=nestri-build /artifacts/nestri/.manifest /tmp/nestri.manifest
RUN cat /tmp/mesa.manifest /tmp/nestri.manifest > /tmp/.strip-manifest && \
rm -f /tmp/mesa.manifest /tmp/nestri.manifest
COPY --from=mesa-build /artifacts/mesa /
COPY --from=nestri-build /artifacts/nestri /
RUN ldconfig
COPY build/etc/ /etc/
RUN chmod +x /etc/init.d/*
# dbus-session, pipewire and wireplumber carry no conf.d of their own — they
# just want the shared environment, so their conf.d is a symlink to it rather
# than a copy. nescope, neshub and neswire are deliberately absent from this
# loop: each has a conf.d file of its own (already landed by the COPY above)
# that sources nestri-user-env, because each needs settings the shared file
# does not carry. Symlinking them here would overwrite those.
RUN for svc in dbus-session pipewire wireplumber; do \
ln -sf nestri-user-env "/etc/conf.d/${svc}"; \
done
RUN echo nesbox > /etc/hostname && dbus-uuidgen --ensure=/etc/machine-id
# Session mount points. The guest root is read-only at runtime, so a runtime
# mkdir gets EROFS and takes a service down before it starts — these have to
# already exist in the image.
RUN mkdir -p /nestri/install /nestri/user /nestri/work /nestri/game /nestri/logs && \
chmod 0755 /nestri /nestri/install /nestri/user /nestri/work /nestri/game /nestri/logs && \
mkdir -p /dev/shm && chmod 1777 /dev/shm && \
rm -f /etc/network/interfaces
# Serial console: the only way into a guest that will not boot.
RUN rm -f /etc/inittab && \
ln -sf agetty /etc/init.d/agetty.hvc0 && \
{ grep -qx hvc0 /etc/securetty || echo hvc0 >> /etc/securetty; }
# Every init.d script calling nestri_export_env needs a conf.d that actually
# defines it. Worth a build-time check: the failure at runtime is nearly
# invisible — OpenRC sources the script, the undefined function is a
# "command not found" on stderr, sourcing still returns 0, and the service
# starts anyway with HOME and every XDG_* unset.
RUN missing=""; \
for svc_script in /etc/init.d/*; do \
grep -qE '^[[:space:]]*nestri_export_env\b' "$svc_script" 2>/dev/null || continue; \
svc="$(basename "$svc_script")"; \
. "/etc/conf.d/$svc" >/dev/null 2>&1; \
command -v nestri_export_env >/dev/null 2>&1 || missing="$missing $svc"; \
done; \
[ -z "$missing" ] || { echo "init scripts call nestri_export_env with no conf.d providing it:$missing" >&2; exit 1; }
# ── OpenRC service registration ──
# sysinit
RUN rc-update add devfs sysinit && \
rc-update add dmesg sysinit && \
rc-update add udev sysinit && \
rc-update add udev-trigger sysinit && \
(rc-update del kmod-static-nodes sysinit || true)
# boot — guest-net replaces the distro's networking scripts entirely; it
# declares `provide net` and brings up lo itself.
RUN (rc-update del networking boot || true) && \
(rc-update del network-async boot || true) && rm -f /etc/init.d/network-async && \
rc-update add guest-net boot && \
(rc-update del net.lo boot || true) && \
(rc-update del netmount boot || true) && \
(rc-update del netmount default || true) && \
rc-update add hostname boot && \
rc-update add xdg-runtime boot && \
rc-update add cgroups boot && \
(rc-update del syslog boot || true)
# Boot-runlevel services with nothing to do in a freshly-built microVM: no
# physical console, no swap, and fsck would be checking a filesystem nobody
# has touched.
RUN for svc in fsck keymaps save-keymaps termencoding save-termencoding swap binfmt seedrng hwclock swclock; do \
(rc-update del "$svc" boot || true); \
(rc-update del "$svc" shutdown || true); \
done
# default — the user-facing stack. nescope is registered in plain-compositor
# mode (no payload after `--`): it comes up and waits for something to
# connect. Actually starting a payload is nesinit's job, and nesinit is not
# open code yet — see build/README.md.
RUN rc-update add agetty.hvc0 default && \
for n in 1 2 3 4 5 6; do (rc-update del "agetty.tty${n}" default || true); done && \
rc-update add dbus default && \
rc-update add dbus-session default && \
rc-update add pipewire default && \
rc-update add wireplumber default && \
rc-update add neshub default && \
rc-update add neswire default && \
rc-update add nescope default && \
rm -f /etc/init.d/shared-root && (rc-update del shared-root boot || true)
# ───────────────────────────────────────────────────────────
# runtime_prod — the default: `make build`
# ───────────────────────────────────────────────────────────
FROM runtime AS runtime_prod
RUN passwd -l root
COPY build/etc/conf.d/agetty.hvc0.prod /etc/conf.d/agetty.hvc0
RUN while IFS= read -r f; do \
[ -f "$f" ] && strip --strip-unneeded "$f" 2>/dev/null || true; \
done < /tmp/.strip-manifest; \
rm -rf /tmp/.strip-manifest /var/cache/pacman/pkg/* /tmp/* /root/.cache \
/usr/share/man /usr/share/doc /usr/share/locale \
/usr/lib/cmake /usr/lib/pkgconfig /usr/share/pkgconfig /usr/include
RUN echo "NESTRI_STAGE=runtime_prod" >> /etc/os-release
# ───────────────────────────────────────────────────────────
# runtime_debug — `make build-debug`
# ───────────────────────────────────────────────────────────
FROM runtime AS runtime_debug
RUN pacman -Syu --noconfirm --needed vulkan-tools mesa-utils libva-utils && \
pacman -Scc --noconfirm
RUN echo 'root:nestri' | chpasswd
COPY build/etc/conf.d/agetty.hvc0.debug /etc/conf.d/agetty.hvc0
RUN echo "NESTRI_STAGE=runtime_debug" >> /etc/os-release

View File

@@ -1,29 +0,0 @@
# The guest rootfs build's context.
#
# A `<Dockerfile>.dockerignore` *replaces* the repository-wide `.dockerignore`
# rather than adding to it, so the first block below is that file repeated. The
# second is what only this build excludes.
#
# This build's context is the repository root (see `Makefile`), and it COPYs
# the workspace manifests plus the Rust members and nothing else. The
# TypeScript half is therefore dead weight in the context — a few megabytes
# sent to the daemon versus the whole tree.
.git
node_modules
target
build/output
.env
.env.*
.wrangler
dist
.output
docs
apps/api
apps/auth
packages
*.md
deno.lock
bun.lock
.zed
.github

View File

@@ -1,12 +1,12 @@
SHELL := /bin/bash
.PHONY: build build-debug image image-debug clean help
.PHONY: build build-debug image image-debug proton-image proton-push clean help
CONTAINER_RT := $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
CONTAINER_RT := $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null)
ifeq ($(CONTAINER_RT),)
$(error "Neither docker nor podman found in PATH")
endif
# The Dockerfile COPYs from apps/ and crates/, so the build context is the
# The Containerfile COPYs from apps/ and crates/, so the build context is the
# repo root, not this directory — same reason borealis's build/ *is* its own
# context: here the guest source lives one level up instead of inside build/.
CONTEXT := ..
@@ -27,17 +27,47 @@ CONTEXT := ..
# where it does not exist. One name, always pullable, beats a local-only
# short name and a different published one.
IMAGE_NAME := ghcr.io/nestrilabs/nestri/base
# Proton is the one artifact worth publishing on its own: it takes hours to
# build and changes only when its tag moves, so the guest build pulls it by
# name instead of rebuilding it.
#
# `PROTON_TAG` is the single place to change it. The published version is
# derived from the tag rather than written twice, because the two are the same
# number in two spellings and an image whose name does not say which Proton is
# inside it is worse than no image at all. Changing the tag by hand and
# forgetting the version is exactly the mistake this removes.
PROTON_TAG ?= cachyos-11.0-20260703-native
PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%)
PROTON_IMAGE ?= ghcr.io/nestrilabs/proton-cachyos-native-wow64
PROTON_REF := $(PROTON_IMAGE):$(PROTON_VERSION)
OUTPUT_DIR := output
ROOTFS_SIZE ?= 5G
ROOTFS_SIZE ?= 3G
FORCE_REBUILD ?=
build:
DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \
-f Dockerfile -t $(IMAGE_NAME):latest --target runtime_prod $(CONTEXT)
--build-arg PROTON_IMAGE=$(PROTON_REF) \
-f Containerfile -t $(IMAGE_NAME):latest --target runtime_prod $(CONTEXT)
build-debug:
DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \
-f Dockerfile -t $(IMAGE_NAME):debug --target runtime_debug $(CONTEXT)
--build-arg PROTON_IMAGE=$(PROTON_REF) \
-f Containerfile -t $(IMAGE_NAME):debug --target runtime_debug $(CONTEXT)
# Hours, and only when PROTON_TAG moves. Its context is this directory rather
# than the repository root: the two scripts beside the Containerfile are the
# whole input, and the root would hand it everything else for nothing.
proton-image:
DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \
--build-arg PROTON_TAG=$(PROTON_TAG) \
-f Containerfile.proton -t $(PROTON_REF) .
@echo "Built $(PROTON_REF)"
# Publishing is what makes `make build` cheap for everyone else, since that
# build pulls this by name. Push before expecting anyone to use a new tag.
proton-push: proton-image
$(CONTAINER_RT) push $(PROTON_REF)
# Not `sudo make image`/sudo'd in here: mkimage.sh runs as you and escalates
# only the specific commands that need root. Under rootless Podman, `make
@@ -62,5 +92,8 @@ help:
@echo " make image Build + pack runtime_prod into output/rootfs.ext4"
@echo " make image-debug Build + pack runtime_debug into output/rootfs-debug.ext4"
@echo " make clean Remove output/"
@echo " make proton-image Build Proton from source (hours)"
@echo " make proton-push Build it and publish it"
@echo " make FORCE_REBUILD=1 ... Rebuild from scratch, no layer cache"
@echo " make PROTON_TAG=... ... Use a different proton-cachyos tag"
@echo " make ROOTFS_SIZE=8G image Override the packed image size (default 5G)"

View File

@@ -1,16 +1,17 @@
# build/ — the guest rootfs
Builds a bootable Artix/OpenRC image for the box's virtio-blk root: Mesa
(virtio-gpu native context) plus the four open guest components —
[`nescope`](../apps/nescope), [`neshub`](../apps/neshub),
[`neswire`](../apps/neswire), [`nescapture`](../apps/nescapture) — laid out
Builds a bootable Arch image for the box's virtio-blk root: Mesa (virtio-gpu
native context) plus the five open guest components —
[`nesinit`](../apps/nesinit), [`nescope`](../apps/nescope),
[`neshub`](../apps/neshub), [`neswire`](../apps/neswire),
[`nescapture`](../apps/nescapture) — laid out
the way [borealis](https://chromium.googlesource.com/chromiumos/overlays/board-overlays/+/main/project-borealis)
lays out its `build/`: one big multi-stage `Dockerfile`, `--target` picks the
lays out its `build/`: one big multi-stage `Containerfile`, `--target` picks the
flavor, `etc/` holds the files that get overlaid onto the image verbatim.
```
build/
├── Dockerfile everything, in stages: mesa-build, nestri-build,
├── Containerfile everything, in stages: mesa-build, nestri-build,
│ os-base, runtime, runtime_prod, runtime_debug
├── etc/ overlaid onto the image's /etc as-is
├── scripts/
@@ -24,6 +25,9 @@ make build # docker build --target runtime_prod → ghcr.io/nestrilab
make build-debug # docker build --target runtime_debug → ghcr.io/nestrilabs/nestri/base:debug
make image # + pack into output/rootfs.ext4
make image-debug # + pack into output/rootfs-debug.ext4
make proton-image # build Proton from source — hours
make proton-push # build it and publish it
```
## Design notes
@@ -33,10 +37,10 @@ Three things worth knowing about how this is put together:
1. **No privileged host chroot.** A bare `chroot` into a hand-extracted
rootfs needs `/proc`, `/sys`, `/dev` bind-mounted in first — they don't
exist inside a chroot target until something puts them there. `os-base`
here is `FROM artixlinux/artixlinux:base-openrc` directly, with
`pacman -S` as plain `RUN` steps — a Docker build step already runs
inside a real container with its own `/proc`, `/sys`, `/dev`, so that
whole bind-mount mechanism has nothing to do.
here is `FROM archlinux:base` directly, with `pacman -S` as plain `RUN`
steps — a Docker build step already runs inside a real container with its
own `/proc`, `/sys`, `/dev`, so that whole bind-mount mechanism has nothing
to do.
2. **No host-side ownership bug to guard against.** `COPY --from=` runs as
root inside the build with no host user in the loop, so there's no
@@ -50,44 +54,194 @@ Three things worth knowing about how this is put together:
own incremental compiler per-crate isolation without needing a separate
Docker stage (and a separate full rebuild of `nesprotocol`) per binary.
**What is deliberately not here: Proton, and Valve's `steamclient.so`.**
**What is deliberately not here: Valve's `steamclient.so`.**
`nestri/CLAUDE.md` is explicit — *"Nothing closed may enter this repo. Not
source, not a dependency, not a directory that 'looked convenient'."* Both
are closed. `runtime_prod` from this Dockerfile — tagged
`ghcr.io/nestrilabs/nestri/base:latest` — is a complete, bootable, Steam-less guest image,
source, not a dependency, not a directory that 'looked convenient'."* That one
is closed, so whatever layers it on top is a build outside this repo — not
something this repo names, links to, or depends on.
**Proton is here, and this paragraph used to say it was not.** The old wording
put Proton and `steamclient.so` together and called both closed, which is
wrong about Proton: it is compiled from source, which is not a thing you can
do with closed software. Keeping it out cost a box the only way it has to run
a Windows title, for a rule that did not apply to it.
What it is: **proton-cachyos built with `--enable-wow64`**, pulled by tag as a
published image rather than rebuilt here, because it takes hours and moves
only when its own tag does. `PROTON_IMAGE` overrides the tag, and it has to be
declared before the first `FROM` — an `ARG` a `FROM` expands is global or it
is nothing, and getting that wrong fails with `no FROM statement found`, which
says nothing about the actual mistake. wow64 is the whole reason it is a build of ours
and not the distribution's package — it runs 32-bit Windows code inside a
64-bit unix process, so a box needs no lib32 glibc, no second Mesa for i686,
and no second capture layer for 32-bit titles to be captured. The
distribution's package is built without the flag, which is exactly why it
depends on `lib32-*`.
It costs about 1.4 GB of image, and it is the one thing in here that is
payload-shaped: a compatibility layer for Windows games in an image that is
otherwise indifferent to what it runs. The guest components stay indifferent
regardless — none of them branches on it, and the init does not know it
exists. What names it is the command a caller sends.
`runtime_prod` from this Containerfile — tagged
`ghcr.io/nestrilabs/nestri/base:latest` — is a complete, bootable guest image,
and also the shared foundation other builds start from: nesbox's jail image
(see `nesbox/build/`) extracts **Mesa** from it so the guest and host sides of
the virtio-gpu native-context protocol never drift apart. Only Mesa —
`virglrenderer` is the host half of that protocol and nesbox builds its own,
patched, from `nesbox/patches/`; nothing in this image carries it.
Whatever layers Proton and the Steam client on top of it is a closed build
outside this repo, by design — not something this repo names, links to, or
depends on.
## Two packages that look droppable and are not
## The nesinit gap
`llvm-libs` is 164 MB, the largest single thing in the image after Proton, and
`lm_sensors` is only there because something links `libsensors`. Both look like
leftovers of a Mesa configuration that has since been trimmed, and both have
been checked rather than reasoned about: **`libgbm` links them**, and the
compositor needs GBM. Trimming the Mesa build does not reach them.
Nothing in this image starts a payload. The old `nestri-guest-hub` did that
— per its own commit message, the open `neshub` *"loses `--proton`,
`--steamclient-so`, `--root` and the game uid/gid, and no longer ends by
handing the process to a controller... Deciding when the box is finished
belongs to `nesinit`."* `nesinit` — the guest init/session-supervisor that
would actually launch `nescope -- <payload>` and power the box down — is
referenced in commit messages and `nesbox/PROGRESS.md` but does not exist as
open code in either repo.
`lm_sensors` in particular was found the hard way. It used to arrive as a
dependency of the distribution's Mesa package, and dropping that package took
it away — leaving our own Mesa unable to resolve `libsensors.so.5`. Nothing in
a package list says that; the check below is what said it.
So `/etc/init.d/nescope` here starts nescope in **plain-compositor mode**
(no command after `--`): it comes up, provides the Wayland/X11 environment,
and waits for something to connect. That makes the image genuinely bootable
and testable — `neshub`, `neswire`, `nescope` all come up under OpenRC and
you get a real Wayland socket to point a client at — but running an actual
game is still nesinit's job, and nesinit isn't part of this build. Whoever
picks that up next should read `apps/neshub/README.md`'s "What it does not
do" section first.
## Proton has its own cadence, and its own Containerfile
`make build` **pulls** Proton by tag; it does not build it. Building it takes
hours and it changes only when its tag moves, so it is one image published
once and copied into every guest image after that. `Containerfile.proton` is
that build, and it lives here so the published tag stays reproducible from
this tree rather than from somebody's laptop.
```sh
make proton-image # the current tag
make PROTON_TAG=cachyos-11.1-20261115-native proton-image
```
**`PROTON_TAG` is the only thing to change.** The published version is derived
from it in the `Makefile` rather than written a second time, because the two
are the same number in two spellings — and an image whose name does not say
which Proton is inside it is worse than no image. The `Containerfile`'s own
`PROTON_IMAGE` default is a fallback for a bare container build; going through
`make` is what keeps them in step.
Its **context is `build/`**, not the repository root the guest build uses. All
it needs is the two scripts beside it, and `Containerfile.proton.containerignore`
keeps `output/` out of that context — a build context is copied before the
first instruction runs, so without it every Proton build would begin by moving
the last rootfs image it produced.
Two things in the recipe are worth knowing before changing it:
- **Fetch and build are separate layers on purpose.** The submodule checkout
runs well past ten minutes, and a build that fails on a flag or a missing
tool must not pay for that again. Keep anything that can fail *fast* in
`proton-build.sh`.
- **`widl` is built by hand from the mingw-w64 release.** Without it autoconf
quietly sets `HAVE_WIDL` to false, vkd3d's public headers are never
generated, and the build dies an hour later on a missing header. Arch ships
`widl` only inside `wine`, which wants multilib — which is the thing
`--enable-wow64` exists to avoid.
## There is no init system in here, and that is the design
`nesinit` is PID 1. The image carries **no service manager, no init scripts,
no `udev` and no systemd** — `systemd-libs` stays, because `dbus-daemon` and
`wireplumber` link `libsystemd.so.0`, but nothing in the image can be PID 1
except `nesinit`, and the build fails if anything that could be turns up.
That is why this is plain Arch. The image used to be Artix, chosen for OpenRC,
and every cost of that choice — no `eudev`, no `agetty-openrc`, `udev` being
systemd's anyway, a runlevel edit not stopping a service another one still
needs — was paid for an init system that is no longer here.
**What replaced fourteen `rc-update` lines and nine init scripts:**
| was | now |
|---|---|
| `devfs`, `dmesg`, `udev`, `udev-trigger` | `devtmpfs` makes the nodes; init sets the two modes that matter. The compositor takes input through Wayland and opens nothing `udev` provides |
| `guest-net`, `hostname`, `xdg-runtime`, `cgroups` | init, before it dials out |
| `dbus`, `dbus-session`, `pipewire`, `wireplumber`, `neshub`, `neswire` | a table compiled into `nesinit` |
| `nescope` in the `default` runlevel | **not a service.** It wraps the workload and is started by a launch, with that launch's geometry, and dies with it |
| `agetty` on `hvc0` | nothing. See below |
| `/etc/fstab` | init's own mounts, and shares named in the boot descriptor |
**A box is launched into, not booted into something.** Init mounts what the
descriptor names, brings the table up, says it is ready, and then takes
commands — so an image on its own runs nothing at all, which is the point:
this image is payload-independent and there is no payload in it.
### Getting into a guest that will not boot
`init=/bin/bash` on the kernel command line. Nothing in the image offers a
login prompt — there is no getty in either flavour — and that is cheaper than
carrying one: `nesinit` is an ordinary program, so from that shell you can run
it by hand and watch it fail. `make build-debug` adds `vulkaninfo` and friends
and gives root a password for `su`; it does not add a console.
**A shell is not a booted box, and the difference bites immediately.** Nothing
the init does has happened: the root is read-only, `/run` and `/tmp` are still
directories on it rather than tmpfs, `/run/user/1000` is unwritable, and the
hostname is `(none)` rather than `nesbox` — which is the quickest way to tell
the two states apart. A compositor started in that shell fails on its own
socket, and the error names the runtime directory rather than the cause.
So run `nesinit` first. It mounts, prepares the directories, brings the
services up, then fails to reach a control channel that is not there and
exits — **leaving everything it prepared behind**, which is exactly what makes
the hand-run useful. Then start what you came to debug.
It is safe to run outside a box, and that took fixing: the shutdown path signals
every process it may signal and then powers the machine off, which is right for
PID 1 of a box and catastrophic anywhere else. Both steps are refused when it is
not PID 1, and it says so rather than doing it quietly.
If you would rather not run it at all, the two mounts it does that a compositor
needs are:
```sh
mount -t tmpfs -o mode=1777,size=64m tmpfs /tmp
mount -t tmpfs -o mode=755,size=32m tmpfs /run
mkdir -p /run/user/1000 && chown 1000:1000 /run/user/1000 && chmod 0700 /run/user/1000
```
Making `/run/user/1000` writable in the *image* does not help, and is worth
saying because it is the obvious first thing to try: before the init runs, the
directory is on a read-only root, so its ownership is not what stops a write;
after the init runs, a fresh tmpfs is mounted over `/run` and the image's copy
of the directory is hidden underneath it.
The one thing this does not reach is a failure *before* the shell. If that
happens the evidence is on `console=hvc0` and nowhere else.
### Two build-time checks worth knowing about
Both exist because the failure they catch is invisible at runtime rather than
loud, which is the same reason the old build checked its `conf.d` files:
- **No hook may point at a program that is not in the image.** Removing
systemd removes the script `dbus-reload.hook` calls, and a leftover hook
produces `error: command failed to execute correctly` on every future pacman
transaction — indistinguishable, in a log, from something that matters.
- **Nothing `nesinit` will look for may be missing.** Its service table is
compiled in, so an absent `dbus-daemon` is not a build error by itself; it
is a service that does not come up in a box somebody is waiting on.
## Network defaults
`/etc/init.d/guest-net` reads `nestri.ip=`/`nestri.gw=` off the kernel
command line, falling back to `172.30.0.2/24` via `172.30.0.1` if neither is
set — nesbox's own default tap addressing. Keep these in step if that
changes on the nesbox side.
`nesinit` reads `nestri.ip=`/`nestri.gw=` off the kernel command line,
falling back to `172.30.0.2/24` via `172.30.0.1` if neither is set — the
host's own default tap addressing. Keep these in step if that changes on the
host side. A box started with no network device at all is a valid box and
boots without one.
The address is a per-boot parameter rather than an image setting because the
alternative makes every box built from this image the same host on the
network, and two of them collide the moment they run together. Same reasoning
for `/etc/machine-id`, which is a symlink into a tmpfs that init fills at
boot — the previous image baked one in, so every box built from it was the
same machine to anything that asked.
This is also the one thing in the image that keeps `iproute2` installed:
init runs `ip` rather than talking netlink, which is a hundred lines of
`unsafe` saved for an interface configured once.

View File

@@ -1,3 +0,0 @@
baud="115200"
term_type="vt100"
agetty_options="--autologin root --noclear"

View File

@@ -1,3 +0,0 @@
baud="115200"
term_type="vt100"
agetty_options="--noclear"

View File

@@ -1,16 +0,0 @@
# Configuration for nescope, the headless compositor.
#
# Sources nestri-user-env rather than symlinking it, for the same reason
# neshub and neswire do.
. /etc/conf.d/nestri-user-env
RUST_LOG="${RUST_LOG:-nescope=info}"
export RUST_LOG
# Started in plain-compositor mode by /etc/init.d/nescope (no command after
# `--`): it comes up and waits for something to connect rather than wrapping
# a payload. Starting an actual payload — a game, a desktop — is nesinit's
# job, and nesinit is not open code yet. See build/README.md.
NESCOPE_SOCKET="${NESCOPE_SOCKET:-nescope-0}"
NESCOPE_INPUT_IPC="${NESCOPE_INPUT_IPC:-/tmp/nestri-input.sock}"
export NESCOPE_SOCKET NESCOPE_INPUT_IPC

View File

@@ -1,18 +0,0 @@
# Configuration for neshub, the media hub.
#
# Not a symlink to nestri-user-env like pipewire and friends: neshub wants a
# setting of its own, so it sources that file rather than replacing it — same
# pattern as nescope and neswire below.
. /etc/conf.d/nestri-user-env
# What neshub logs, and nothing else.
#
# Scoped and exported for the reason spelled out at length in the old
# nestri-guest-hub conf.d this replaces: an unscoped `RUST_LOG=info` turns on
# every crate linked in, iroh included, and OpenRC hands a service its own
# environment, not the shell's locals — an unexported variable here goes
# nowhere.
#
# Overridable, so a bad boot can be re-run with more without a rebuild.
RUST_LOG="${RUST_LOG:-neshub=info}"
export RUST_LOG

View File

@@ -1,34 +0,0 @@
HOME="/home/nestri"
USER="nestri"
LOGNAME="nestri"
XDG_RUNTIME_DIR="/run/user/1000"
XDG_CONFIG_HOME="/home/nestri/.config"
XDG_DATA_HOME="/home/nestri/.local/share"
XDG_CACHE_HOME="/home/nestri/.cache"
XDG_STATE_HOME="/home/nestri/.local/state"
XDG_DATA_DIRS="/usr/local/share:/usr/share"
XDG_SESSION_TYPE="wayland"
XDG_SESSION_DESKTOP="nestri"
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/1000/bus"
# Driver forcing — currently off, in both copies. Kept, commented, because
# the reasoning still applies if it is ever needed again: capture happens
# through a Vulkan layer, so a process reaching the GPU via native OpenGL
# would render fine and capture nothing, and zink is what makes such a
# process capturable at all. Nothing has needed it so far.
#
# /etc/profile.d/nestri-env.sh carries the same block, also commented, and
# only runs for login shells — nothing here is one, every service below runs
# from OpenRC. If these are ever re-enabled, re-enable both: one copy on and
# one off means services and shells reach the GPU by different paths.
#__GLX_VENDOR_LIBRARY_NAME="mesa"
#MESA_LOADER_DRIVER_OVERRIDE="zink"
#GALLIUM_DRIVER="zink"
nestri_export_env() {
export HOME USER LOGNAME
export XDG_RUNTIME_DIR XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME
export XDG_DATA_DIRS XDG_SESSION_TYPE XDG_SESSION_DESKTOP
export DBUS_SESSION_BUS_ADDRESS
#export __GLX_VENDOR_LIBRARY_NAME MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER
}

View File

@@ -1,32 +0,0 @@
# Configuration for the audio wire.
#
# Not a symlink to nestri-user-env like pipewire and friends, for the same
# reason neshub is not: it needs a setting of its own -- and it still wants
# that file's environment, so it sources it rather than replacing it.
. /etc/conf.d/nestri-user-env
# What neswire logs, and nothing else.
#
# Without this neswire is *silent* -- it starts, fails, and exits leaving an
# empty log, which is indistinguishable from never having been started at
# all. That ambiguity is the reason this file exists.
#
# Scoped, and `export`ed, for the reasons spelled out at length in
# conf.d/neshub: an unscoped `info` turns on every linked crate, and an
# unexported one reaches the service's shell and not the service.
#
# Overridable, so a bad boot can be re-run with more without a rebuild.
RUST_LOG="${RUST_LOG:-neswire=info}"
export RUST_LOG
# neswire's own defaults, spelled out rather than inherited.
#
# All four are clap args with defaults (`neswire --help`), so the service
# works without them. They are here because the IPC path is a contract with
# neshub -- it reads this socket -- and a contract that lives only inside two
# binaries' default values is one nobody can check.
NESWIRE_IPC_PATH="/tmp/nestri-audio.sock"
NESWIRE_CHANNELS="2"
NESWIRE_PACKET_DURATION_MS="5"
NESWIRE_BITRATE_PER_CHANNEL="64"
export NESWIRE_IPC_PATH NESWIRE_CHANNELS NESWIRE_PACKET_DURATION_MS NESWIRE_BITRATE_PER_CHANNEL

View File

@@ -1,35 +0,0 @@
# `ro`, matching how a box is actually started: nesbox passes `ro` on the
# kernel command line and marks the root device `is_read_only: true` (see
# nesbox's examples/vm.json), so the virtio-blk device refuses writes at the
# device level. Saying `rw` here does not make it writable — it only asks
# OpenRC's `root` service to attempt a remount that the device must reject.
# Everything a running box writes to is a tmpfs or a share below.
/dev/vda / ext4 ro,relatime 0 1
devtmpfs /dev devtmpfs rw,nosuid 0 0
proc /proc proc rw,nosuid,nodev,noexec 0 0
sysfs /sys sysfs rw,nosuid,nodev,noexec 0 0
tmpfs /tmp tmpfs rw,nosuid,nodev,size=64M 0 0
tmpfs /run tmpfs rw,nosuid,nodev,size=32M,mode=0755 0 0
tmpfs /var/log tmpfs rw,nosuid,nodev,size=16M 0 0
# POSIX shared memory. devtmpfs does not provide it and nothing else mounts it,
# so without this entry /dev/shm does not exist at all and `shm_open` fails.
#
# PipeWire itself gets by: it allocates buffers with memfd_create and only
# falls back to /dev/shm. Kept as a precaution for anything else that uses
# POSIX shm/semaphores directly — Proton is the known example, and it is not
# part of this image yet (see build/README.md), but a failure here is silent
# rather than fatal, so it costs nothing to have ready.
#
# `nosuid,nodev` and a size cap because everything a game can write to should
# have both.
tmpfs /dev/shm tmpfs rw,nosuid,nodev,size=256M 0 0
# The guest's logs, on a host directory that outlives the VM.
#
# Here rather than mounted by a service, because the failures worth reading
# are the ones that happen *before* anything else has mounted something — a
# log directory that appears only after a successful start cannot record an
# unsuccessful one. The tag is fixed by nessh, so plain fstab works.
#
# `nofail` because a VM started without the share still has to boot: that is
# how somebody gets a shell to find out why it has no share.
logs /nestri/logs virtiofs rw,nofail 0 0

View File

@@ -1,33 +0,0 @@
#!/sbin/openrc-run
description="D-Bus session bus for nestri user"
nestri_export_env
command="/usr/bin/dbus-daemon"
command_args="--session --address=unix:path=/run/user/1000/bus --nofork --nopidfile --print-address"
command_user="nestri:nestri"
command_background="yes"
pidfile="/run/nestri/dbus-session.pid"
depend() {
need xdg-runtime dbus
before pipewire wireplumber
}
start_pre() {
checkpath -d -m 0755 -o "nestri:nestri" /run/nestri
if [ -e "/run/user/1000/bus" ]; then
ewarn "Removing stale bus socket"
rm -f "/run/user/1000/bus"
fi
}
start_post() {
local i=0
while [ ! -S /run/user/1000/bus ] && [ $i -lt 20 ]; do
sleep 0.1
i=$((i+1))
done
[ -S /run/user/1000/bus ] || { eerror "Bus didn't appear"; return 1; }
}

View File

@@ -1,19 +0,0 @@
#!/sbin/openrc-run
description="D-Bus system message bus"
command="/usr/bin/dbus-daemon"
command_args="--system --nofork --nopidfile"
command_background="yes"
pidfile="/run/dbus/dbus.pid"
depend() {
need localmount xdg-runtime
before pipewire
}
start_pre() {
checkpath -d -m 0755 -o root:root /run/dbus
checkpath -d -m 0755 -o messagebus:messagebus /var/run/dbus 2>/dev/null || true
dbus-uuidgen --ensure=/var/lib/dbus/machine-id
}

View File

@@ -1,92 +0,0 @@
#!/sbin/openrc-run
description="Configure the guest's network to match the host's tap"
# Overridden from /etc/conf.d/guest-net if present. These defaults match
# nesbox's own defaults; if you change the `network` section in the VM's
# JSON, change these to match.
: ${GUEST_IFACE:=eth0}
: ${GUEST_IP:=172.30.0.2}
: ${GUEST_PREFIX:=24}
: ${GUEST_GATEWAY:=172.30.0.1}
depend() {
need localmount
provide net
keyword -shutdown
}
# Read one `nestri.<key>=<value>` from the kernel command line.
#
# The address has to come from somewhere per-boot, because the alternative --
# baking it into the image -- makes every guest built from that image the
# same host on the network. Two sandboxes then collide the moment they run
# together.
#
# `nestri.`-prefixed rather than the kernel's own `ip=`: that one needs
# CONFIG_IP_PNP and exists to configure NFS root, and the prefix makes it
# obvious whose parameter this is.
cmdline_value() {
local key="$1" word
for word in $(cat /proc/cmdline 2>/dev/null); do
case "$word" in
"nestri.${key}="*) printf '%s' "${word#nestri.${key}=}"; return 0 ;;
esac
done
return 1
}
start() {
ebegin "Bringing up loopback"
ip link set lo up
eend $?
# The command line wins over conf.d when it says anything, and conf.d is
# the fallback so a hand-written VM config with no parameters keeps
# working -- which is how a guest gets debugged.
local source="/etc/conf.d/guest-net"
local cmdline_ip
if cmdline_ip="$(cmdline_value ip)"; then
# Accepts address/prefix; a bare address keeps the configured prefix
# rather than guessing one.
case "$cmdline_ip" in
*/*)
GUEST_IP="${cmdline_ip%%/*}"
GUEST_PREFIX="${cmdline_ip##*/}"
;;
*) GUEST_IP="$cmdline_ip" ;;
esac
source="kernel command line"
fi
local cmdline_gw
if cmdline_gw="$(cmdline_value gw)"; then
GUEST_GATEWAY="$cmdline_gw"
source="kernel command line"
fi
# The VM may have been started with no network device at all, which is a
# perfectly good configuration. Do not fail the boot over it.
if [ ! -e "/sys/class/net/${GUEST_IFACE}" ]; then
einfo "no ${GUEST_IFACE}: this VM has no network device"
return 0
fi
# Says which source won, because "the address is wrong" and "the address
# came from somewhere unexpected" look identical from inside the guest.
ebegin "Configuring ${GUEST_IFACE} as ${GUEST_IP}/${GUEST_PREFIX} via ${GUEST_GATEWAY} (from ${source})"
# `replace` rather than `add` so a restart is not an error.
ip link set "${GUEST_IFACE}" up &&
ip addr replace "${GUEST_IP}/${GUEST_PREFIX}" dev "${GUEST_IFACE}" &&
ip route replace default via "${GUEST_GATEWAY}" dev "${GUEST_IFACE}"
eend $? "could not configure ${GUEST_IFACE}"
}
stop() {
if [ -e "/sys/class/net/${GUEST_IFACE}" ]; then
ebegin "Bringing down ${GUEST_IFACE}"
ip link set "${GUEST_IFACE}" down
eend 0
fi
return 0
}

View File

@@ -1,33 +0,0 @@
#!/sbin/openrc-run
description="Nestri headless compositor"
nestri_export_env
: "${NESTRI_UID:=1000}"
: "${NESTRI_USER:=nestri}"
command="/usr/bin/nescope"
# No command after `--`: plain-compositor mode. nescope comes up and waits
# for something to connect rather than wrapping a payload — starting one is
# nesinit's job, and nesinit is not open code yet. See build/README.md.
command_user="${NESTRI_USER}:${NESTRI_USER}"
command_background="yes"
pidfile="/run/nestri/nescope.pid"
output_log="/nestri/logs/nescope.log"
error_log="/nestri/logs/nescope.log"
respawn="yes"
respawn_delay="2"
respawn_max="2"
export XDG_RUNTIME_DIR="/run/user/${NESTRI_UID}"
depend() {
need xdg-runtime
use neshub
after xdg-runtime
}
start_pre() {
checkpath -d -m 0755 -o "${NESTRI_USER}:${NESTRI_USER}" /run/nestri
}

View File

@@ -1,39 +0,0 @@
#!/sbin/openrc-run
description="Nestri media hub"
nestri_export_env
: "${NESTRI_UID:=1000}"
: "${NESTRI_USER:=nestri}"
command="/usr/bin/neshub"
# Unprivileged. The old nestri-guest-hub ran as root and mounted the
# session's filesystem itself; the open neshub does neither — per its own
# README it only muxes the Unix sockets the other components dial into one
# QUIC endpoint. Whatever ends up owning session mounts (nesinit, presumably)
# is a separate, still-closed piece.
command_user="${NESTRI_USER}:${NESTRI_USER}"
command_background="yes"
pidfile="/run/nestri/neshub.pid"
# Where neshub's output goes. /nestri/logs is a virtiofs share mounted from
# /etc/fstab at boot, before this service starts, so a hub that fails
# immediately still leaves a record, and the record survives the VM.
output_log="/nestri/logs/neshub.log"
error_log="/nestri/logs/neshub.log"
respawn="yes"
respawn_delay="2"
respawn_max="2" # NO infinite — if it fails, it fails for a reason
export XDG_RUNTIME_DIR="/run/user/${NESTRI_UID}"
depend() {
need xdg-runtime
use net
after xdg-runtime
}
start_pre() {
checkpath -d -m 0755 -o "${NESTRI_USER}:${NESTRI_USER}" /run/nestri
}

View File

@@ -1,93 +0,0 @@
#!/sbin/openrc-run
description="Nestri pipewire audio sink"
nestri_export_env
: "${NESTRI_UID:=1000}"
: "${NESTRI_USER:=nestri}"
command="/usr/bin/neswire"
command_user="${NESTRI_USER}:${NESTRI_USER}"
command_background="yes"
pidfile="/run/nestri/neswire.pid"
output_log="/nestri/logs/neswire.log"
error_log="/nestri/logs/neswire.log"
respawn="yes"
respawn_delay="2"
respawn_max="2"
export XDG_RUNTIME_DIR="/run/user/${NESTRI_UID}"
export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${NESTRI_UID}/bus"
depend() {
# wireplumber is a hard dependency, not a nicety: start_post below pins
# the default sink through the `default` metadata object, and
# WirePlumber is what owns that object. Started concurrently, the pin
# lands in an object pw-metadata created itself, which is destroyed the
# moment it exits.
need xdg-runtime dbus dbus-session pipewire wireplumber
use neshub
after pipewire wireplumber neshub
}
start_pre() {
checkpath -d -m 0755 -o "${NESTRI_USER}:${NESTRI_USER}" /run/nestri
}
# Point the graph at neswire's sink, by name.
#
# `neswire` registers itself as media.class = Audio/Sink, node.name = neswire
# ("Neswire Cloud Gaming Audio Sink"). With the hardware monitors off it is
# the only sink, so WirePlumber's find-best hook should land on it anyway --
# this makes it explicit rather than a consequence of there being nothing
# else, so adding a second sink later cannot silently steal the default.
#
# `default.configured.audio.sink` is the metadata WirePlumber's find-selected
# hook reads, and it takes a name; `wpctl set-default` takes a numeric object
# id that changes every boot, which is why this uses pw-metadata instead.
#
# Not fatal if it fails: find-best still has one candidate. A game playing
# into the wrong sink is silent, and a warning here is the only place that
# would say so.
start_post() {
local i=0
while [ $i -lt 50 ]; do
pw-dump 2>/dev/null | grep -q '"node.name": "neswire"' && break
sleep 0.1
i=$((i+1))
done
if ! pw-dump 2>/dev/null | grep -q '"node.name": "neswire"'; then
ewarn "neswire started but its sink never appeared in the graph"
return 0
fi
# Wait for WirePlumber to own the `default` metadata before writing to it.
#
# `need wireplumber` only guarantees its script returned, not that it has
# built its objects. Writing too early is silently useless rather than an
# error: pw-metadata creates the object, sets the key, exits 0, and the
# object dies with the client. So wait for the object to exist, and say
# so if it never does.
i=0
while [ $i -lt 50 ]; do
pw-metadata -n default >/dev/null 2>&1 && break
sleep 0.1
i=$((i+1))
done
if ! pw-metadata -n default >/dev/null 2>&1; then
ewarn "no 'default' metadata: WirePlumber is not running, sink not pinned"
return 0
fi
pw-metadata -n default 0 default.configured.audio.sink '{ "name": "neswire" }' \
>/dev/null 2>&1
# Read it back. A write that did not stick is the failure this whole
# sequence exists to catch, and it is invisible unless checked.
if pw-metadata -n default 2>/dev/null | grep -q "neswire"; then
einfo "default sink pinned to neswire"
else
ewarn "pinned neswire as default sink but it did not stick"
fi
}

View File

@@ -1,45 +0,0 @@
#!/sbin/openrc-run
description="PipeWire multimedia daemon (system mode for nestri)"
nestri_export_env
: "${NESTRI_UID:=1000}"
: "${NESTRI_USER:=nestri}"
command="/usr/bin/pipewire"
command_user="${NESTRI_USER}:${NESTRI_USER}"
command_background="yes"
pidfile="/run/pipewire/pipewire.pid"
export XDG_RUNTIME_DIR="/run/user/${NESTRI_UID}"
export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${NESTRI_UID}/bus"
depend() {
need xdg-runtime dbus
use dbus
}
start_pre() {
checkpath -d -m 0755 -o "${NESTRI_USER}:${NESTRI_USER}" /run/pipewire
}
# `need pipewire` only waits for this script to return, and with
# command_background that is the moment start-stop-daemon forks -- not the
# moment PipeWire is accepting connections. With rc_parallel="YES" every
# client (wireplumber, neswire) then races the socket and a client that
# loses simply exits.
#
# dbus-session already solved exactly this for the bus socket. Same shape
# here.
start_post() {
local i=0
while [ ! -S "/run/user/${NESTRI_UID}/pipewire-0" ] && [ $i -lt 50 ]; do
sleep 0.1
i=$((i+1))
done
[ -S "/run/user/${NESTRI_UID}/pipewire-0" ] || {
eerror "PipeWire socket never appeared"
return 1
}
}

View File

@@ -1,20 +0,0 @@
#!/sbin/openrc-run
description="WirePlumber session manager for PipeWire"
nestri_export_env
: "${NESTRI_UID:=1000}"
: "${NESTRI_USER:=nestri}"
command="/usr/bin/wireplumber"
command_user="${NESTRI_USER}:${NESTRI_USER}"
command_background="yes"
pidfile="/run/pipewire/wireplumber.pid"
export XDG_RUNTIME_DIR="/run/user/${NESTRI_UID}"
depend() {
need pipewire
after pipewire
}

View File

@@ -1,44 +0,0 @@
#!/sbin/openrc-run
description="Create runtime directories (XDG_RUNTIME_DIR, X11 socket dir..)"
NESTRI_UID="${NESTRI_UID:-1000}"
NESTRI_USER="${NESTRI_USER:-nestri}"
depend() {
need localmount
before dbus pipewire
}
start() {
ebegin "Preparing runtime directories"
if ! getent passwd "${NESTRI_USER}" >/dev/null 2>&1; then
eerror "User ${NESTRI_USER} does not exist"
return 1
fi
# Per-user XDG runtime dir
mkdir -p "/run/user/${NESTRI_UID}"
chown "${NESTRI_USER}:${NESTRI_USER}" "/run/user/${NESTRI_UID}"
chmod 0700 "/run/user/${NESTRI_UID}"
# X11 socket dir (Xwayland + any X clients expect this)
mkdir -p /tmp/.X11-unix
chown root:root /tmp/.X11-unix
chmod 1777 /tmp/.X11-unix
# ICE socket dir - some toolkits look for it
mkdir -p /tmp/.ICE-unix
chown root:root /tmp/.ICE-unix
chmod 1777 /tmp/.ICE-unix
eend $?
}
stop() {
ebegin "Removing runtime directories"
rm -rf "/run/user/${NESTRI_UID}"
# Don't rm /tmp/.X11-unix on stop - other things may be using it
eend 0
}

View File

@@ -14,14 +14,22 @@ if [ "$(id -u)" = "1000" ]; then
export XDG_SESSION_TYPE="${XDG_SESSION_TYPE:-wayland}"
export XDG_SESSION_DESKTOP="${XDG_SESSION_DESKTOP:-nestri}"
# Ensure proper VAAPI driver is used
#export LIBVA_DRIVER_NAME="radeonsi"
# Force zink usage for OpenGL -> Vulkan translation
#export __GLX_VENDOR_LIBRARY_NAME=mesa
#export MESA_LOADER_DRIVER_OVERRIDE=zink
#export GALLIUM_DRIVER=zink
# Ensure standard XDG dirs exist
mkdir -p "${XDG_CONFIG_HOME}" "${XDG_DATA_HOME}" "${XDG_CACHE_HOME}" "${XDG_STATE_HOME}" 2>/dev/null || true
fi
# OpenGL -> Vulkan, for anybody who reaches a shell in here.
#
# Outside the uid test above on purpose, and duplicated from the init on
# purpose. This file is only ever read by a person who got a shell in a box --
# nothing a box runs is started from a login, so every service and every
# workload is exec'd with a cleared environment and never sees this. The init
# sets the same three for what it starts.
#
# It is here so that a debug shell renders the way a session does. A shell that
# silently has no GL driver is how somebody concludes the image is broken while
# the image is fine.
export __GLX_VENDOR_LIBRARY_NAME=mesa
export MESA_LOADER_DRIVER_OVERRIDE=zink
export GALLIUM_DRIVER=zink

View File

@@ -1,27 +0,0 @@
# Nestri rootfs OpenRC config
rc_parallel="YES"
rc_depend_strict="NO"
rc_interactive="NO"
rc_shell_timeout="0"
# Every service's start/fail line, on the host directory that outlives the VM.
#
# Without this a guest nobody can log into reports nothing about its own boot:
# a service that was skipped because a dependency failed looks exactly like a
# service that was never registered. /nestri/logs is a virtiofs share, so the
# record survives the guest — see the fstab entry below.
rc_logger="YES"
rc_log_path="/nestri/logs/rc.log"
rc_verbose="NO"
# cgroup v2
rc_cgroup_mode="unified"
# Don't bother trying to set hostname twice
rc_hotplug="!net.*"
# Faster sulogin behavior on emergency
rc_shell="/bin/sh"
# Default umask
umask 022

View File

@@ -15,14 +15,14 @@ set -euo pipefail
IMAGE="${1:?usage: mkimage.sh <image-tag> <output-path> [size]}"
OUT="${2:?usage: mkimage.sh <image-tag> <output-path> [size]}"
SIZE="${3:-5G}"
SIZE="${3:-3G}"
if [[ "$(id -u)" -eq 0 ]]; then
echo "mkimage.sh should run as yourself, not root/sudo — see the comment at the top of this script" >&2
exit 1
fi
CONTAINER_RT="$(command -v docker || command -v podman || true)"
CONTAINER_RT="$(command -v podman || command -v docker || true)"
[[ -n "$CONTAINER_RT" ]] || { echo "Neither docker nor podman found in PATH" >&2; exit 1; }
sudo -v # cache credentials once, rather than prompting mid-pipeline

96
build/scripts/proton-build.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Builds proton-cachyos from the tree proton-fetch.sh laid down. Container-only.
#
# The one thing that matters here is --enable-wow64: it builds wine so that
# 32-bit Windows code runs inside a 64-bit unix process, thunking down to the
# 64-bit host libraries. Without it, Proton needs a complete 32-bit host stack —
# lib32 glibc, a second Mesa built for i686, and a second nescapture layer,
# because a 32-bit game would load the 32-bit Vulkan loader and our 64-bit
# capture layer would be invisible to it. With it, none of that exists.
#
# The cost is that the distro package cannot be used: proton-cachyos-native is
# packaged without the flag, which is exactly why it depends on lib32-*.
set -euo pipefail
: "${GECKO_VER:?}"
: "${MONO_VER:?}"
JOBS="${JOBS:-$(nproc)}"
BUILD_NAME="proton-cachyos"
SRC_DIR="/build/proton-cachyos"
BUILD_DIR="/build/build"
OUT_DIR="/artifacts/proton/usr/share/steam/compatibilitytools.d/${BUILD_NAME}"
[[ -d "${SRC_DIR}" ]] || { echo "no source tree — proton-fetch.sh did not run"; exit 1; }
# ── Toolchain wrappers ──────────────────────────────────
# Proton's build calls the compiler by GNU triplet. Arch's gcc does not install
# under those names, so stand in for them. The i686 set is generated too: with
# wow64 nothing should reach for it, and if something does, failing on a missing
# 32-bit header beats silently building a 32-bit unix library we then have to
# ship libraries for.
WRAP=/build/wrappers
rm -rf "$WRAP" && mkdir -p "$WRAP"
_wrappers() {
local arch="$1" gccflag="$2" ldflag="$3" asflag="$4" stripfmt="$5"
local l t
for l in ar ranlib nm; do
ln -sf "/usr/bin/gcc-${l}" "${WRAP}/${arch}-pc-linux-gnu-${l}"
done
for t in gcc g++; do
printf '#!/usr/bin/bash\n/usr/bin/%s %s "$@"\n' "$t" "$gccflag" \
> "${WRAP}/${arch}-pc-linux-gnu-${t}"
chmod 755 "${WRAP}/${arch}-pc-linux-gnu-${t}"
done
printf '#!/usr/bin/bash\n/usr/bin/ld %s "$@"\n' "$ldflag" > "${WRAP}/${arch}-pc-linux-gnu-ld"
printf '#!/usr/bin/bash\n/usr/bin/as %s "$@"\n' "$asflag" > "${WRAP}/${arch}-pc-linux-gnu-as"
printf '#!/usr/bin/bash\n/usr/bin/strip -F %s "$@"\n' "$stripfmt" > "${WRAP}/${arch}-pc-linux-gnu-strip"
chmod 755 "${WRAP}/${arch}-pc-linux-gnu-"{ld,as,strip}
}
_wrappers x86_64 "-m64" "-melf_x86_64" "--64" "elf64-x86-64"
_wrappers i686 "-m32" "-melf_i386" "--32" "elf32-i386"
export PATH="${WRAP}:${PATH}"
# ── Build ───────────────────────────────────────────────
# -march=nocona matches the distro packaging: Proton has to run on whatever CPU
# the guest is given, and the VMM does not promise a feature level.
export CFLAGS="-O3 -march=nocona -mtune=core-avx2"
export CXXFLAGS="${CFLAGS}"
export RUSTFLAGS="-C opt-level=3 -C target-cpu=nocona"
export LDFLAGS="-Wl,-O1,--sort-common,--as-needed"
export RUSTUP_TOOLCHAIN=stable
mkdir -p "${BUILD_DIR}"
cd "${BUILD_DIR}"
ROOTLESS_CONTAINER="" \
"${SRC_DIR}/configure.sh" \
--container-engine="none" \
--proton-sdk-image="" \
--build-name="${BUILD_NAME}" \
--without-extras=all \
--without-vklayers=all \
--without-steamrt-depends \
--without-tts \
--without-nvidia-libs \
--enable-wow64
# The top-level make is serial by design; SUBJOBS is what it hands to each
# component's build.
SUBJOBS="${JOBS}" make -j1 dist
# ── Install ─────────────────────────────────────────────
mkdir -p "${OUT_DIR}"
cp -a "${BUILD_DIR}/dist/." "${OUT_DIR}/"
# Debug symbols in the bundled PE runtimes are dead weight in a guest image.
cd "${OUT_DIR}/files"
find "share/wine/gecko/wine-gecko-${GECKO_VER}-x86" -name '*.dll' -o -name '*.exe' 2>/dev/null \
| xargs -r i686-w64-mingw32-strip --strip-debug 2>/dev/null || true
find "share/wine/gecko/wine-gecko-${GECKO_VER}-x86_64" -name '*.dll' -o -name '*.exe' 2>/dev/null \
| xargs -r x86_64-w64-mingw32-strip --strip-debug 2>/dev/null || true
find "share/wine/mono/wine-mono-${MONO_VER}" -name '*.dll' -o -name '*.exe' 2>/dev/null \
| xargs -r i686-w64-mingw32-strip --strip-debug 2>/dev/null || true
rm -rf "${BUILD_DIR}"
echo "proton: installed to ${OUT_DIR}"

57
build/scripts/proton-fetch.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Fetches proton-cachyos' source and its bundled runtimes. Container-only.
#
# Deliberately its own script, and its own layer: the submodule checkout runs
# well past ten minutes, and it must not be redone every time a build flag or a
# missing dependency changes. Keep everything that can fail *fast* in
# proton-build.sh instead.
set -euo pipefail
: "${PROTON_GIT:?}"
: "${PROTON_TAG:?}"
: "${GECKO_VER:?}"
: "${MONO_VER:?}"
: "${XALIA_VER:?}"
SRC_DIR="/build/proton-cachyos"
git clone --branch "${PROTON_TAG}" --depth=1 "${PROTON_GIT}" "${SRC_DIR}"
cd "${SRC_DIR}"
# Relative submodule paths resolve against origin, so it has to be the real URL
# even though we cloned by tag.
git remote set-url origin "${PROTON_GIT}"
# No --depth here: submodules are pinned to commits that are often not a branch
# tip. --filter=tree:0 keeps the download down instead.
git submodule update --init --filter=tree:0 --recursive
# Still needed with wow64: these are PE-side, and a 32-bit Windows program wants
# the 32-bit gecko and mono regardless of how wine is built.
mkdir -p contrib
for url in \
"https://dl.winehq.org/wine/wine-gecko/${GECKO_VER}/wine-gecko-${GECKO_VER}-x86.tar.xz" \
"https://dl.winehq.org/wine/wine-gecko/${GECKO_VER}/wine-gecko-${GECKO_VER}-x86_64.tar.xz" \
"https://github.com/madewokherd/wine-mono/releases/download/wine-mono-${MONO_VER}/wine-mono-${MONO_VER}-x86.tar.xz" \
"https://github.com/madewokherd/xalia/releases/download/xalia-${XALIA_VER}/xalia-${XALIA_VER}-net48-mono.zip" \
; do
curl -fL --retry 3 -o "contrib/$(basename "$url")" "$url"
done
# Proton's cargo rule runs `cargo build --locked --offline`, so every crate has
# to be in CARGO_HOME before the build starts — including the git dependencies,
# which is what the "you are in the offline mode" failure is really saying. The
# error names a URL that is perfectly reachable; the build simply refuses to go
# out and get it.
#
# gst-plugins-rs is the only cargo component in the tree. Both targets are
# fetched: wow64 should mean nothing builds the i386 unix side, but a fetch is
# metadata only and costs almost nothing next to being wrong about that.
#
# CARGO_HOME is left at its default so it lands in this layer and the build
# layer inherits it.
export CARGO_NET_GIT_FETCH_WITH_CLI=true
export RUSTUP_TOOLCHAIN=stable
cd "${SRC_DIR}/gst-plugins-rs"
cargo fetch --locked --target x86_64-unknown-linux-gnu
cargo fetch --locked --target i686-unknown-linux-gnu
echo "proton: source at ${SRC_DIR}"