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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
96
tundra/vm/init.py
Normal file
96
tundra/vm/init.py
Normal 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
71
tundra/vm/probe.sys.mjs
Normal 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
95
tundra/vm/session.py
Normal 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
41
tundra/vm/usb-test.py
Normal 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
12
tundra/vm/weston.ini
Normal 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
|
||||
Loading…
Reference in a new issue