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

96
tundra/vm/init.py Normal file
View file

@ -0,0 +1,96 @@
#!/usr/bin/python3
"""Guest PID 1. Start the graphical session and retain serial diagnostics."""
import json
import os
from pathlib import Path
import signal
import subprocess
import time
import traceback
if os.getpid() != 1:
raise SystemExit('This init runs only as PID 1 inside the VM.')
os.environ.update(PATH='/usr/bin', HOME='/root', LANG='C.UTF-8', LD_LIBRARY_PATH='/usr/lib/weston:/usr/lib')
children = []
def run(*args):
subprocess.run(args, check=True)
def start(*args, env=None):
p = subprocess.Popen(args, env=env)
children.append(p)
return p
def shutdown(*unused):
print('TUNDRA_SHUTDOWN', flush=True)
for p in reversed(children):
if p.poll() is None: p.terminate()
time.sleep(2)
os.sync()
# Kernel poweroff; this process cannot run on the host due to the PID1 guard.
import ctypes
ctypes.CDLL(None).reboot(0x4321FEDC)
signal.signal(signal.SIGTERM, shutdown)
try:
Path('/dev/shm').mkdir(exist_ok=True)
run('mount','-t','tmpfs','tmpfs','/dev/shm')
Path('/dev/pts').mkdir(exist_ok=True)
run('mount','-t','devpts','devpts','/dev/pts')
for path in ('/run/user/1000','/home/vulpes','/var/log/tundra'):
Path(path).mkdir(parents=True, exist_ok=True)
os.chown(path,1000,1000)
os.chmod('/run/user/1000',0o700)
# Staged files belong to the builder; the guest grants only product state to its user.
for base, dirs, files in os.walk('/opt/vulpes'):
os.chown(base,1000,1000)
for name in files: os.chown(Path(base)/name,1000,1000)
run('ip','link','set','lo','up')
start('/usr/lib/systemd/systemd-udevd','--resolve-names=never')
# Coldplug must wait for the daemon's control socket, otherwise its initial
# events can be lost and libinput sees neither keyboard nor pointer.
for _ in range(100):
ping = subprocess.run(['udevadm','control','--timeout=1','--ping'],
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if ping.returncode == 0: break
time.sleep(.1)
else: raise RuntimeError('udevd did not become ready')
run('udevadm','trigger','--action=add')
run('udevadm','settle','--timeout=10')
inputs = []
for device in Path('/dev/input').glob('event*'):
info = subprocess.check_output(['udevadm','info','--query=property',str(device)],text=True)
inputs.append(info)
if not any('ID_INPUT_MOUSE=1' in item for item in inputs):
raise RuntimeError('Virtual pointer missing from udev: '+repr(inputs))
if not any('ID_INPUT_KEYBOARD=1' in item for item in inputs):
raise RuntimeError('Virtual keyboard missing from udev: '+repr(inputs))
print('TUNDRA_INPUT_READY',flush=True)
if 'tundra.usb=1' in Path('/proc/cmdline').read_text():
run('python3','-u','/opt/tundra/usb-test.py')
env = dict(os.environ, SEATD_VTBOUND='0')
start('seatd','-g','video',env=env)
for _ in range(50):
if Path('/run/seatd.sock').exists(): break
time.sleep(.1)
env.update(HOME='/home/vulpes',USER='vulpes',LOGNAME='vulpes',
XDG_RUNTIME_DIR='/run/user/1000',LIBSEAT_BACKEND='seatd',
WAYLAND_DISPLAY='wayland-0',MOZ_ENABLE_WAYLAND='1',GDK_BACKEND='wayland',
XDG_SESSION_TYPE='wayland',LIBGL_ALWAYS_SOFTWARE='1')
print('TUNDRA_GRAPHICAL_START', flush=True)
user = ['setpriv','--reuid=1000','--regid=1000','--groups=44,992']
session = start(*user,'dbus-run-session','--','python3','-u','/opt/tundra/session.py',env=env)
while session.poll() is None: time.sleep(1)
print('TUNDRA_SESSION_EXIT',session.returncode,flush=True)
except Exception:
traceback.print_exc()
print('TUNDRA_BOOT_FAILED',flush=True)
finally:
for name in ('weston.log','gecko.log','server.log','test.json','usb.log','usb-test.json'):
p = Path('/var/log/tundra')/name
if p.exists():
print('TUNDRA_LOG_BEGIN', name, flush=True)
print(p.read_text(errors='replace')[-18000:],flush=True)
print('TUNDRA_LOG_END', name, flush=True)
shutdown()
while True: time.sleep(1)

71
tundra/vm/probe.sys.mjs Normal file
View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: MPL-2.0
// Guest-only smoke test. Never opens a Firefox browser/debugger window.
import { setTimeout } from 'resource://gre/modules/Timer.sys.mjs';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export async function run(win) {
const report = {passed:false, checks:{}, engine:{
version:Services.appinfo.platformVersion, buildID:Services.appinfo.appBuildID,
OS:Services.appinfo.OS}};
const test = Services.env.get('TUNDRA_TEST') === '1';
if(test) {
report.pointerEvents=[];
for(const type of ['mousemove','mousedown','mouseup','click']) {
win.addEventListener(type,event=>{
report.pointerEvents.push({type,x:event.clientX,y:event.clientY,
target:event.target.id,trusted:event.isTrusted});
if(report.pointerEvents.length>20) report.pointerEvents.shift();
},true);
}
}
async function evaluate(app, code) {
const prefix = `http://${app}.localhost:8765/`;
function find(c) {
if(c.currentWindowGlobal?.documentURI.spec.startsWith(prefix)) return c;
for(const child of c.children) { const found=find(child); if(found) return found; }
return null;
}
const context = win.document.querySelector('browser')?.browsingContext;
const child = context && find(context);
if(!child) return null;
return child.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',code);
}
async function wait(app, code) {
let last;
for(let attempt=0;attempt<120;attempt++) {
try { last=await evaluate(app,code); if(last) return last; }
catch(error) {last=String(error);}
await sleep(500);
}
throw new Error(`${app}: timed out: ${code}: ${last}`);
}
try {
const lock=await IOUtils.readJSON('/opt/vulpes/engine-lock.json');
if(report.engine.version!==lock.version) throw new Error('Wrong Gecko version');
ChromeUtils.registerWindowActor('VulpesProbe',{
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
allFrames:true,safeForUntrustedWebProcess:true});
report.checks.system=await wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';");
report.checks.home=await wait('homescreen',"return document.readyState==='complete' && document.body.innerText.length>0;");
report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || getComputedStyle(logo).opacity==='0';");
report.checks.visible=await wait('system',"return document.visibilityState==='visible';");
if(test) {
for(const app of ['settings','clock']) {
await evaluate('system',`await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp',entryPoint:null});return true;`);
await wait(app,"return document.readyState==='complete' && document.body.innerText.length>20;");
report.checks[app]=await wait('system',`return [...document.querySelectorAll('.appWindow.active[transition-state=opened] iframe')].some(f=>f.src.startsWith('http://${app}.localhost:8765/'));`);
}
// This signal requests a real host QMP input, not a synthetic DOM click.
dump('TUNDRA_POINTER_HOME\n');
report.checks.pointerHome=await wait('system',`return !!document.querySelector('.appWindow.active[transition-state=opened] iframe[src*="homescreen.localhost"]');`);
await sleep(2000);
}
report.passed=true;
} catch(error) {
report.host={visibility:win.document.visibilityState,active:win.document.querySelector('browser')?.docShellIsActive,layers:win.document.querySelector('browser')?.renderLayers};
report.error=String(error)+'\n'+error.stack;
try {report.state=await evaluate('system',"return {visibility:document.visibilityState,logo:document.getElementById('os-logo')?.className,apps:[...document.querySelectorAll('.appWindow')].map(e=>({id:e.id,state:e.getAttribute('transition-state')}))};");} catch(_) {}
}
await IOUtils.writeJSON('/var/log/tundra/test.json',report);
dump('TUNDRA_GUI_TEST '+JSON.stringify(report)+'\n');
if(test && report.passed) setTimeout(()=>Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit),15000);
}

95
tundra/vm/session.py Normal file
View file

@ -0,0 +1,95 @@
#!/usr/bin/python3
"""A non-root Wayland session using the shared, unmodified Vulpes host."""
import importlib.util
import json
import os
from pathlib import Path
import socket
import shutil
import subprocess
import sys
import time
root = Path('/opt/vulpes')
sys.path.insert(0,str(root/'tools'))
spec = importlib.util.spec_from_file_location('host',root/'tools/run-host.py')
host = importlib.util.module_from_spec(spec)
spec.loader.exec_module(host)
lock = json.loads((root/'engine-lock.json').read_text())
test='tundra.test=1' in Path('/proc/cmdline').read_text()
shutil.copy2('/opt/tundra/probe.sys.mjs',root/'tests/TundraProbe.sys.mjs')
runtime = host.prepare(lock, test=True)
with (runtime/'vulpes.cfg').open('a') as cfg:
cfg.write('''
Services.obs.addObserver({observe(win) {
win.addEventListener("load", () => {
if(win.document.documentElement.getAttribute("windowtype")==="vulpes:host")
ChromeUtils.importESModule("resource://vulpes/tests/TundraProbe.sys.mjs").run(win).catch(e=>dump("TUNDRA_PROBE_ERROR "+e+"\\n"));
},{once:true});
}}, "domwindowopened");
''')
logs = Path('/var/log/tundra')
processes = []
def start(args, log, **kwargs):
p = subprocess.Popen(args,stdout=(logs/log).open('w'),stderr=subprocess.STDOUT,**kwargs)
processes.append(p)
return p
try:
weston = start(['weston','--backend=drm','--renderer=pixman','--shell=kiosk-shell.so',
'--config=/opt/tundra/weston.ini','--idle-time=0','--socket=wayland-0',
],'weston.log')
for _ in range(150):
if weston.poll() is not None: raise RuntimeError('Weston exited')
if Path(os.environ['XDG_RUNTIME_DIR'],'wayland-0').exists(): break
time.sleep(.1)
else: raise RuntimeError('Wayland socket missing')
server = start(['python3','-u',str(root/'tools/serve-gaia.py')],'server.log')
for _ in range(100):
if server.poll() is not None: raise RuntimeError('Gaia server exited')
try:
with socket.create_connection(('127.0.0.1',8765),timeout=.2): break
except OSError: time.sleep(.1)
else: raise RuntimeError('Gaia server did not start')
profile = root/'profiles/tundra-vm'
profile.mkdir(parents=True,exist_ok=True)
prefs = {'browser.shell.checkDefaultBrowser':False,'browser.dom.window.dump.enabled':True,
'browser.startup.homepage_override.mstone':'ignore','browser.aboutwelcome.enabled':False,
'font.default.x-western':'sans-serif','font.name.sans-serif.x-western':'Fira Sans',
'marionette.enabled':False,
'datareporting.policy.dataSubmissionEnabled':False,'toolkit.telemetry.enabled':False,
'app.update.auto':False,
# Pixman/virtio has no reliable Wayland frame clock while idle.
'widget.wayland.vsync.enabled':False}
(profile/'user.js').write_text('\n'.join(f'user_pref({json.dumps(k)}, {json.dumps(v)});' for k,v in prefs.items())+'\n')
env = dict(os.environ,VULPES_FULLSCREEN='1',TUNDRA_TEST='1' if test else '0',VULPES_HOST_ROOT=str(runtime/'vulpes/host'),VULPES_WORKSPACE_ROOT=str(root))
firefox = start([str(runtime/'firefox'),'-no-remote','-profile',str(profile),
'-chrome','chrome://vulpes/content/shell.xhtml'],'gecko.log',env=env)
forwarded=False
for _ in range(1800):
if firefox.poll() is not None: raise RuntimeError('Gecko exited before checks completed')
if not forwarded and 'TUNDRA_POINTER_HOME' in (logs/'gecko.log').read_text(errors='replace'):
print('TUNDRA_POINTER_HOME',flush=True);forwarded=True
if (logs/'test.json').exists(): break
time.sleep(.1)
else: raise RuntimeError('Guest test timeout')
report=json.loads((logs/'test.json').read_text())
report['uid']=os.getuid()
report['checks']['nonRoot']=os.getuid()!=0
report['passed']=report['passed'] and report['checks']['nonRoot']
(logs/'test.json').write_text(json.dumps(report,indent=2)+'\n')
print('TUNDRA_GUI_TEST '+json.dumps(report),flush=True)
if not report['passed']: raise RuntimeError('Guest checks failed')
print('TUNDRA_GUI_READY',flush=True)
if 'tundra.test=1' in Path('/proc/cmdline').read_text():
time.sleep(20) # Host captures the actual virtio display before shutdown.
else:
while firefox.poll() is None and weston.poll() is None: time.sleep(1)
finally:
for p in reversed(processes):
if p.poll() is None:
p.terminate()
try: p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill(); p.wait()

41
tundra/vm/usb-test.py Normal file
View file

@ -0,0 +1,41 @@
#!/usr/bin/python3
# SPDX-License-Identifier: MPL-2.0
"""Exercise both directions of FunctionFS through the guest's dummy USB controller."""
import json
import os
from pathlib import Path
import subprocess
import time
from usb_protocol import exchange
def main():
if os.getpid()==1 or 'tundra.usb=1' not in Path('/proc/cmdline').read_text():
raise SystemExit('Guest USB test requires its explicit VM kernel flag')
for module in ('dummy_hcd','libcomposite','usb_f_fs','usbcore'):
subprocess.run(['modprobe',module],check=True)
log=Path('/var/log/tundra/usb.log').open('w')
proc=subprocess.Popen(['unshare','--mount','--pid','--fork','/opt/tundra/usb-init'],stdout=log,stderr=subprocess.STDOUT)
device=None
for _ in range(200):
for p in Path('/sys/bus/usb/devices').iterdir():
try:
if (p/'serial').read_text().strip()=='TUNDRA-DIAG' and (p/'idVendor').read_text().strip()=='1d6b':
device=Path('/dev/bus/usb')/f'{int((p/"busnum").read_text()):03d}'/f'{int((p/"devnum").read_text()):03d}'
if device.exists():break
except (FileNotFoundError,NotADirectoryError):pass
if device and device.exists():break
if proc.poll() is not None:raise RuntimeError('Diagnostic process exited')
time.sleep(.1)
else:raise RuntimeError('USB gadget did not enumerate: '+Path('/var/log/tundra/usb.log').read_text())
results=exchange(device,('status','version','mounts','forbidden'))
assert 'readOnly=true' in results['status']
assert 'Linux version' in results['version']
assert 'configfs' in results['mounts']
assert results['forbidden']=='ERROR unsupported command\nEND\n'
report={'passed':True,'scope':'dummy_hcd x86_64 guest; not physical sargo USB', 'results':results}
Path('/var/log/tundra/usb-test.json').write_text(json.dumps(report,indent=2)+'\n')
print('TUNDRA_USB_TEST '+json.dumps(report),flush=True)
if __name__=='__main__':main()

12
tundra/vm/weston.ini Normal file
View file

@ -0,0 +1,12 @@
[core]
idle-time=0
[shell]
panel-position=none
background-type=solid
background-color=0xff111827
locking=false
[output]
name=Virtual-1
# Keep the phone viewport independent of QEMU GTK's initial console size.
mode=47.25 600 640 696 792 960 963 973 996 -hsync +vsync