Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

254
tundra/tools/build-vm.py Normal file
View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Assemble an isolated x86 graphical prototype from pinned Vulpes and local Linux tools.
This is a development image, not a redistributable distro or a Pixel ROM.
No root, package installation, personal profile or host filesystem mount is needed.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import time
import urllib.request
from tundra import cpio
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
OUT = ROOT / 'out/graphical-vm'
FS = OUT / 'root'
WESTON = ROOT / 'out/graphical'
seen = set()
def sha(path):
with open(path, 'rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def logical(path):
s = str(path)
for prefix in ('/lib64/', '/lib/'):
if s.startswith(prefix):
return '/usr/lib/' + s[len(prefix):]
return s
def copy(path, dest=None, deps=True):
path = Path(path)
dest = logical(dest or path)
target = FS / dest.lstrip('/')
if dest in seen:
return
if not path.is_file():
raise FileNotFoundError(path)
seen.add(dest)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
shutil.copy2(path, target, follow_symlinks=True)
target.chmod(target.stat().st_mode & 0o777)
if deps and path.open('rb').read(4) == b'\x7fELF':
runtime = PROJECT / json.loads((PROJECT / 'engine-lock.json').read_text())['runtime']
env = dict(os.environ, LD_LIBRARY_PATH=f'{WESTON}/usr/lib/weston:{WESTON}/usr/lib:{runtime}')
result = subprocess.run(['ldd', str(path)], env=env, capture_output=True, text=True)
if 'not found' in result.stdout:
raise RuntimeError(f'Missing dependency for {path}: {result.stdout}')
for library in re.findall(r'(?:=>\s+|^\s*)(/\S+)', result.stdout, re.M):
destination = library
if library.startswith(str(WESTON)):
destination = library[len(str(WESTON)):]
elif library.startswith(str(PROJECT / 'engines')):
destination = '/opt/vulpes/' + str(Path(library).relative_to(PROJECT))
copy(library, destination)
def tree(source, destination, exclude=()):
source = Path(source)
for base, dirs, files in os.walk(source, followlinks=True):
dirs[:] = [d for d in dirs if d not in exclude]
for name in files:
p = Path(base) / name
if name.endswith(('.pyc', '.pyo')) or name in exclude:
continue
if p.is_file():
copy(p, str(Path(destination) / p.relative_to(source)))
def write(path, data, mode=0o644):
p = FS / path.lstrip('/')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(data)
p.chmod(mode)
def initramfs(kernel):
minimal = OUT / 'initroot'
minimal.mkdir(exist_ok=True)
paths = set()
def include(path):
p = Path(logical(path))
if str(p) in paths:
return
paths.add(str(p))
output = subprocess.run(['ldd', str(p)], capture_output=True, text=True).stdout
for lib in re.findall(r'(?:=>\s+|^\s*)(/\S+)', output, re.M):
include(lib)
for binary in ('bash', 'mount', 'modprobe', 'switch_root', 'mkdir', 'sleep'):
include('/usr/bin/' + binary)
modules = set()
for module in ('virtio_pci', 'virtio_blk', 'ext4', 'virtio_gpu', 'virtio_input', 'evdev', 'dummy_hcd', 'libcomposite', 'usb_f_fs', 'usbcore'):
output = subprocess.check_output(['modprobe', '--show-depends', module], text=True)
for line in output.splitlines():
if line.startswith('insmod '):
modules.add(logical(line.split()[1]))
paths.update(modules)
moddir = Path('/usr/lib/modules') / kernel
paths.update(str(p) for p in moddir.glob('modules.*') if p.is_file())
entries = []
dirs = {'dev', 'proc', 'sys', 'newroot', 'run', 'usr', 'usr/bin', 'usr/lib'}
for path in sorted(paths):
p = Path(path)
name = path.lstrip('/')
dirs.update(str(d) for d in Path(name).parents if str(d) != '.')
entries.append((name, stat.S_IFREG | (p.stat().st_mode & 0o777), p.read_bytes(), 0, 0))
copy(p, deps=False)
script = '''#!/usr/bin/bash
set -eux
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
mount -t tmpfs tmpfs /run
for m in virtio_pci virtio_blk ext4 virtio_gpu virtio_input evdev; do modprobe "$m"; done
for ((i=0;i<100;i++)); do [[ -b /dev/vda ]] && break; sleep .1; done
mount /dev/vda /newroot
mkdir -p /newroot/{dev,proc,sys}
mount --move /dev /newroot/dev
mount --move /proc /newroot/proc
mount --move /sys /newroot/sys
mount --move /run /newroot/run
exec switch_root /newroot /usr/bin/python3 -u /opt/tundra/init.py
'''
entries = [(d, stat.S_IFDIR | 0o755, b'', 0, 0) for d in sorted(dirs, key=lambda s: (s.count('/'), s))] + entries
entries += [('lib', stat.S_IFLNK | 0o777, b'usr/lib', 0, 0),
('lib64', stat.S_IFLNK | 0o777, b'usr/lib', 0, 0),
('bin', stat.S_IFLNK | 0o777, b'usr/bin', 0, 0),
('init', stat.S_IFREG | 0o755, script.encode(), 0, 0),
('dev/console', stat.S_IFCHR | 0o600, b'', 5, 1)]
(OUT / 'initramfs.cpio.gz').write_bytes(cpio(entries))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--refresh', action='store_true', help='Refresh shared Vulpes/guest files in an existing staged root')
args = parser.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
FS.mkdir(exist_ok=True)
if args.refresh and not (OUT/'build.json').exists():
raise SystemExit('Run a full build before --refresh')
if not (ROOT/'out/usb-diagnostic/x86_64/init').exists():
subprocess.run([sys.executable,str(ROOT/'tools/build-usb.py')],check=True)
if not args.refresh:
lock = json.loads((ROOT / 'manifests/weston.lock.json').read_text())
archive = ROOT / 'downloads/weston' / lock['url'].rsplit('/', 1)[1]
if not archive.exists():
archive.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(lock['url'], timeout=60) as response, archive.with_suffix('.part').open('wb') as output:
shutil.copyfileobj(response, output)
archive.with_suffix('.part').replace(archive)
if sha(archive) != lock['sha256']:
raise RuntimeError('Weston checksum mismatch')
WESTON.mkdir(parents=True, exist_ok=True)
subprocess.run(['tar','--no-same-owner','-xf',str(archive),'-C',str(WESTON)],check=True)
for link, dest in [('bin','usr/bin'), ('sbin','usr/bin'), ('lib','usr/lib'), ('lib64','usr/lib'), ('usr/lib64','lib')]:
p = FS / link
p.parent.mkdir(parents=True, exist_ok=True)
if not p.exists() and not p.is_symlink():
p.symlink_to(dest)
tree(WESTON / 'usr', '/usr', ('vnc-backend.so','rdp-backend.so','pipewire-backend.so','xwayland.so','vulkan-renderer.so'))
for name in ('bash','python3','mount','umount','modprobe','mkdir','sleep','ip','udevadm','dbus-daemon','dbus-run-session','seatd','setpriv','sync','fc-cache','bwrap','unshare'):
copy('/usr/bin/' + name)
copy('/usr/lib/systemd/systemd-udevd')
copy('/usr/bin/sh')
for program in ('at-spi-bus-launcher','at-spi2-registryd','xdg-desktop-portal','xdg-desktop-portal-gtk','xdg-permission-store','xdg-document-portal'):
copy('/usr/lib/'+program)
copy('/usr/lib/libkmod.so.2')
copy('/usr/lib/udev/libinput-device-group')
rules = FS / 'usr/lib/udev/rules.d'
if rules.exists(): shutil.rmtree(rules)
for name in ('60-input-id.rules','60-drm.rules','70-mouse.rules','71-seat.rules','73-seat-late.rules','80-libinput-device-groups.rules','90-libinput-fuzz-override.rules'):
p = Path('/usr/lib/udev/rules.d') / name
if p.exists(): copy(p)
write('/usr/lib/udev/rules.d/50-tundra.rules', 'SUBSYSTEM=="input", GROUP="video", MODE="0660"\nSUBSYSTEM=="drm", GROUP="video", MODE="0660"\nSUBSYSTEM=="tty", KERNEL=="tty[0-9]*", GROUP="video", MODE="0660"\n')
python = Path(sys.base_prefix) / 'lib' / f'python{sys.version_info.major}.{sys.version_info.minor}'
tree(python, str(python), ('site-packages','__pycache__','test','tests','idlelib','tkinter','ensurepip'))
for dirname in ('/usr/share/xdg-desktop-portal','/usr/share/mime','/usr/lib/glycin-loaders','/usr/share/glycin-loaders','/usr/lib/locale/C.utf8','/usr/share/libinput','/usr/share/X11/xkb','/usr/share/glvnd/egl_vendor.d','/usr/share/glib-2.0/schemas','/usr/lib/gdk-pixbuf-2.0','/usr/share/dbus-1','/etc/fonts'):
tree(dirname, dirname)
for name in ('libEGL.so.1','libEGL_mesa.so.0','libGL.so.1','libGLX.so.0','libGLX_mesa.so.0','libGLESv2.so.2','libgbm.so.1','libgtk-3.so.0','libgdk-3.so.0','libnss_dns.so.2','libnss_files.so.2'):
p = Path('/usr/lib') / name
if p.exists(): copy(p)
for name in ('swrast_dri.so','virtio_gpu_dri.so'):
copy('/usr/lib/dri/' + name)
for gallium in Path('/usr/lib').glob('libgallium-*.so'):
copy(gallium)
for font in Path('/usr/share/fonts/TTF').glob('DejaVuSans*.ttf'):
copy(font, deps=False)
for font in (PROJECT / 'gaia/shared').rglob('*.ttf'):
copy(font, '/usr/share/fonts/vulpes/' + font.name, deps=False)
kernel = os.uname().release
shutil.copy2('/boot/vmlinuz-linux', OUT / 'vmlinuz')
initramfs(kernel)
write('/etc/passwd', 'root:x:0:0:root:/root:/usr/bin/bash\nvulpes:x:1000:1000:Vulpes:/home/vulpes:/usr/bin/bash\n')
write('/etc/group', 'root:x:0:\nvideo:x:44:vulpes\nrender:x:992:vulpes\nvulpes:x:1000:\n')
write('/etc/nsswitch.conf', 'passwd: files\ngroup: files\nhosts: files dns\n')
write('/etc/hosts', '127.0.0.1 localhost\n::1 localhost\n')
write('/etc/os-release', 'NAME="Tundra development VM"\nID=tundra\nPRETTY_NAME="Tundra x86_64 prototype"\n')
write('/etc/machine-id', 'a30d62a5b7954ddda12e13ab83aaff52\n')
write('/etc/ld.so.conf', '/usr/lib\n/usr/lib/weston\n')
# Refresh the generated payload as a unit so removed source files stay removed.
for name in ('assets','host','services','adapters','overrides','gaia','tools','tests'):
path=FS/'opt/vulpes'/name
if path.exists(): shutil.rmtree(path)
if (FS/'opt/tundra').exists(): shutil.rmtree(FS/'opt/tundra')
# Only distributable product inputs; never the user's profiles, keys or history.
for name in ('assets','host','services','adapters','overrides'):
tree(PROJECT / name, '/opt/vulpes/' + name, ('__pycache__',))
tree(PROJECT / 'gaia/shared', '/opt/vulpes/gaia/shared', ('test','tests'))
tree(PROJECT / 'gaia/apps/system', '/opt/vulpes/gaia/apps/system', ('test','tests'))
for name in ('engine-lock.json','release.json'):
copy(PROJECT / name, '/opt/vulpes/' + name)
for name in ('serve-gaia.py','project.py','run-host.py','marionette_client.py'):
copy(PROJECT / 'tools' / name, '/opt/vulpes/tools/' + name)
copy(PROJECT / 'tests/ProbeChild.sys.mjs', '/opt/vulpes/tests/ProbeChild.sys.mjs')
engine = json.loads((PROJECT / 'engine-lock.json').read_text())
if not args.refresh:
tree(PROJECT / engine['runtime'], '/opt/vulpes/' + engine['runtime'], ('vulpes','updated','updates'))
tree(ROOT / 'vm', '/opt/tundra', ('__pycache__',))
copy(ROOT/'out/usb-diagnostic/x86_64/init','/opt/tundra/usb-init')
copy(ROOT/'tools/usb_protocol.py','/opt/tundra/usb_protocol.py')
for directory in ('dev','proc','sys','run','tmp','var/log','var/cache/fontconfig','home/vulpes','root'):
(FS / directory).mkdir(parents=True, exist_ok=True)
(FS / 'tmp').chmod(0o1777)
subprocess.run(['/usr/bin/ldconfig','-r',str(FS)], check=True)
print('Writing ext4 image…', flush=True)
image = OUT / 'root.ext4'
with image.open('wb') as f:
f.truncate(4 * 1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(ROOT/'tools/pack-rootfs.py'), str(FS), str(image)], check=True)
files = {}
for p in sorted(FS.rglob('*')):
if p.is_file() and not p.is_symlink():
files[str(p.relative_to(FS))] = sha(p)
report = {'scope':'x86_64 Linux/Wayland development VM; no Android HAL or Pixel validation',
'gecko':engine['version'], 'kernel':os.uname().release, 'created':time.time(),
'rootImageSha256':sha(image),'network':'loopback only; no virtual NIC', 'renderer':'Weston Pixman',
'files':files, 'initramfsSha256':sha(OUT/'initramfs.cpio.gz'), 'kernelSha256':sha(OUT/'vmlinuz')}
(OUT / 'build.json').write_text(json.dumps(report, indent=2) + '\n')
print(f'Ready: {image} ({len(files)} files recorded)', flush=True)
if __name__ == '__main__': main()