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

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the six inventoried Sargo boot partitions; never open a device for writing."""
import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import stat
import struct
import subprocess
import tempfile
PARTITIONS = {
'boot_a': 67108864, 'boot_b': 67108864,
'dtbo_a': 8417280, 'dtbo_b': 8417280,
'vbmeta_a': 65536, 'vbmeta_b': 65536,
}
def copy_exact(source, destination, size):
digest = hashlib.sha256()
remaining = size
while remaining:
chunk = source.read(min(1024 * 1024, remaining))
if not chunk:
raise RuntimeError('Partition read ended before its declared size')
destination.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
return digest.hexdigest()
def main():
if os.geteuid() != 0:
raise RuntimeError('Partition reads require sudo in your own terminal')
account = pwd.getpwnam('droidian')
getprop = next((p for p in ('/usr/bin/getprop', '/bin/getprop') if Path(p).exists()), None)
if not getprop:
raise RuntimeError('Cannot establish Android device identity')
device = subprocess.check_output([getprop, 'ro.product.device'], text=True, timeout=10).strip()
compatible = Path('/proc/device-tree/compatible').read_bytes().split(b'\0')
if device != 'sargo' or b'google,b4s4-sdm670' not in compatible:
raise RuntimeError('This recipe only supports the inventoried Sargo adaptation')
os.umask(0o077)
folder = Path(tempfile.mkdtemp(prefix='vulpes-boot-backup-', dir='/var/tmp'))
report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'device': device, 'rawPartitions': True, 'restorationTested': False,
'scope': 'Boot, DTBO and vbmeta only; excludes userdata, vendor and system',
'files': {}}
for name, expected_size in PARTITIONS.items():
path = (Path('/dev/disk/by-partlabel') / name).resolve(strict=True)
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as source:
if not stat.S_ISBLK(os.fstat(source.fileno()).st_mode):
raise RuntimeError('Not a block device: ' + str(path))
size = struct.unpack('Q', fcntl.ioctl(source.fileno(), 0x80081272, bytes(8)))[0]
if size != expected_size:
raise RuntimeError('Partition layout differs from the inventory: ' + name)
target = folder / (name + '.img')
with target.open('xb') as output:
digest = copy_exact(source, output, size)
output.flush()
os.fsync(output.fileno())
with target.open('rb') as saved:
if hashlib.file_digest(saved, 'sha256').hexdigest() != digest:
raise RuntimeError('Backup verification failed: ' + name)
report['files'][target.name] = {'source': str(path), 'bytes': size, 'sha256': digest}
(folder / 'manifest.json').write_text(json.dumps(report, indent=2) + '\n')
# Keep incomplete backups root-only; transfer ownership only after all six pass.
for child in folder.iterdir():
os.chown(child, account.pw_uid, account.pw_gid)
os.chown(folder, account.pw_uid, account.pw_gid)
print('Backup verified:', folder)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Check the ARM64 reference in a private mount namespace, without starting services."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
from urllib.parse import quote
def run(*args, check=True):
result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=45)
if check and result.returncode:
raise RuntimeError(f'{args}: {result.stderr.strip()}')
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, required=True)
parser.add_argument('--product', type=Path, required=True)
parser.add_argument('--inside-namespace', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error('chroot and the private mount namespace require administrator privileges')
root, product = args.root.resolve(), args.product.resolve()
base = Path('/home/droidian/vulpes-tundra-tests')
for path in (root, product):
if not path.is_relative_to(base) or path == base:
parser.error('Expected isolated device trial directories')
if not (root.parent/(root.name+'-stage.json')).is_file():
parser.error('Staging report missing')
if not args.inside_namespace:
result = subprocess.run(['unshare', '--mount', '--net', '--fork', sys.executable,
str(Path(__file__).resolve()), '--inside-namespace', '--root', str(root),
'--product', str(product)], check=False)
raise SystemExit(result.returncode)
if os.readlink('/proc/self/ns/mnt') == os.readlink('/proc/1/ns/mnt'):
parser.error('Refusing to mount in the system namespace')
run('mount', '--make-rprivate', '/')
run('mount', '--bind', str(product), str(root/'opt/vulpes'))
run('mount', '-o', 'remount,bind,ro', str(root/'opt/vulpes'))
run('mount', '-t', 'proc', 'proc', str(root/'proc'))
for name in ('null', 'urandom', 'random'):
(root/'dev'/name).touch(exist_ok=True)
run('mount', '--bind', '/dev/'+name, str(root/'dev'/name))
for name in ('run', 'tmp'):
run('mount', '-t', 'tmpfs', '-o', 'mode=1777,size=128m', 'tmpfs', str(root/name))
os.chown(root/'home/vulpes', 1000, 1000)
profile = root/'home/vulpes/render-profile'
profile.mkdir(exist_ok=True)
os.chown(profile, 1000, 1000)
(root/'home/vulpes/render.png').unlink(missing_ok=True)
run('chroot', str(root), '/sbin/ldconfig')
compiler = '/usr/lib/aarch64-linux-gnu/glib-2.0/glib-compile-schemas'
run('chroot', str(root), compiler, '/usr/share/glib-2.0/schemas')
engine = json.loads((product/'engine-lock.json').read_text())['version']
binary = f'/opt/vulpes/engines/{engine}/firefox/firefox'
commands = {
'python': ['/usr/bin/python3', '-c', 'import platform,ssl,json; print(json.dumps({"machine":platform.machine(),"python":platform.python_version(),"ssl":ssl.OPENSSL_VERSION}))'],
'compositor': ['/usr/bin/phoc', '--version'],
'gecko': [binary, '--headless', '--version'],
'geckoRendering': [binary, '--headless', '--no-remote',
'--profile', '/home/vulpes/render-profile', '--window-size', '360,740',
'--screenshot', '/home/vulpes/render.png',
'data:text/html,'+quote('<!doctype html><meta charset="utf-8"><title>Tundra runtime check</title><body style="background:#123456;color:white">Gecko runtime qualification</body>')],
'geckoLibraries': ['/usr/bin/ldd', binary],
'compositorLibraries': ['/usr/bin/ldd', '/usr/bin/phoc'],
}
results = {}
for name, command in commands.items():
result = run('chroot', '--userspec=1000:1000', str(root), '/usr/bin/env', '-i',
'HOME=/home/vulpes', 'PATH=/usr/bin:/bin:/usr/sbin:/sbin',
'LANG=C', *command, check=False)
results[name] = {'command': command, 'returncode': result.returncode,
'stdout': result.stdout, 'stderr': result.stderr}
report = {'scope': 'Offline non-root chroot programs; not a boot, HAL or graphical qualification',
'engine': engine, 'uid': 1000, 'commands': results, 'sandboxQualified': False,
'passed': all(r['returncode'] == 0 and 'not found' not in r['stdout']
for r in results.values())}
screenshot = root/'home/vulpes/render.png'
if screenshot.is_file():
data = screenshot.read_bytes()
report['render'] = {'sha256': hashlib.sha256(data).hexdigest(),
'width': int.from_bytes(data[16:20], 'big'),
'height': int.from_bytes(data[20:24], 'big')}
report['passed'] &= (data.startswith(b'\x89PNG\r\n\x1a\n') and
report['render']['width'] == 360 and report['render']['height'] == 740)
else:
report['passed'] = False
path = root.parent/'chroot-check.json'
path.write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report, indent=2), flush=True)
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,151 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Bounded replacement of the display session; restore Phosh when the trial ends."""
import argparse
import json
import os
from pathlib import Path
import pwd
import shlex
import signal
import subprocess
import time
UNIT = 'tundra-display-trial.service'
DROPIN = Path('/run/systemd/system/android-service@hwcomposer.service.d/20-phosh.conf')
SERVICE = Path('/run/systemd/system')/UNIT
CONFIG = Path('/run/tundra-phoc-trial.ini')
def command(*args,check=True):
return subprocess.run(args,check=check,capture_output=True,text=True,timeout=45)
def restore(result):
"""Attempt every restoration step even if an earlier command times out."""
errors = []
def attempt(*args):
try:
return command(*args, check=False)
except (OSError, subprocess.SubprocessError) as error:
errors.append({'command': list(args), 'error': str(error)})
return None
attempt('systemctl', 'stop', UNIT)
journal = attempt('journalctl', '-u', UNIT, '--no-pager', '-n', '300')
result['journal'] = journal.stdout if journal else ''
for path in (SERVICE, DROPIN, CONFIG):
try:
path.unlink(missing_ok=True)
except OSError as error:
errors.append({'path': str(path), 'error': str(error)})
attempt('systemctl', 'daemon-reload')
attempt('systemctl', 'reset-failed', UNIT)
attempt('systemctl', 'start', 'phosh.service')
status = attempt('systemctl', 'is-active', 'phosh.service')
result['restoredPhosh'] = status is not None and status.returncode == 0
if errors:
result['cleanupErrors'] = errors
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--product',type=Path,required=True)
parser.add_argument('--seconds',type=int,default=180)
parser.add_argument('--root',type=Path,help='Run Phoc and Gaia from this separate runtime tree; borrow installed Android HAL services')
args=parser.parse_args()
if os.geteuid()!=0:
raise RuntimeError('The display service switch requires administrator privileges')
product=args.product.resolve()
expected=Path('/home/droidian/vulpes-tundra-tests')
if not product.is_relative_to(expected) or not (product/'trial/session.py').is_file():
raise ValueError('Expected an existing isolated trial product')
if command('getprop','ro.product.device').stdout.strip()!='sargo':
raise ValueError('Expected Sargo')
if not 30<=args.seconds<=600:
raise ValueError('Trial duration must be between 30 and 600 seconds')
if any(p.exists() for p in (DROPIN,SERVICE,CONFIG)):
raise RuntimeError('Previous runtime configuration present; restore it before another trial')
if command('systemctl','is-active','phosh.service',check=False).returncode:
raise RuntimeError('Phosh must be active as the known return session')
account=pwd.getpwnam('droidian')
root = args.root.resolve() if args.root else None
if root:
if not root.is_relative_to(expected) or not (root/'usr/bin/phoc').is_file():
raise ValueError('Expected a staged ARM64 runtime under the trial directory')
if not (root/'opt/vulpes/trial/session.py').is_file():
raise ValueError('Common product must be staged under /opt/vulpes in the trial root')
(root/'opt/vulpes/logs').mkdir(exist_ok=True)
os.chown(root/'opt/vulpes/logs',account.pw_uid,account.pw_gid)
child=shlex.join(['/usr/bin/python3',str(Path('/opt/vulpes') if root else product)+'/trial/session.py','--own-compositor','--hybris-egl'])
result={'scope':'Own compositor on installed Droidian; not an independent root filesystem',
'startedAt':time.time(),'seconds':args.seconds,'restoredPhosh':False}
if root:
result.update(scope='Phoc and Gaia in a separate ARM64 root; Android HAL and logind borrowed from installed Droidian',runtimeRoot=str(root))
def interrupted(signum,frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM,interrupted)
try:
DROPIN.parent.mkdir(exist_ok=True)
DROPIN.write_text('[Unit]\nBindsTo=\n')
CONFIG.write_text('[output:HWCOMPOSER-1]\nscale=3\n')
isolation = f'''RootDirectory={root}
MountAPIVFS=yes
PrivateNetwork=yes
BindPaths=/dev /run/user/{account.pw_uid}
BindReadOnlyPaths=/android/system /android/vendor /run/udev /run/dbus /run/systemd/sessions /run/systemd/seats /run/systemd/users /sys {CONFIG}
''' if root else ''
SERVICE.write_text(f'''[Unit]
Description=Temporary Vulpes display qualification
Requires=android-service@hwcomposer.service
After=android-service@hwcomposer.service systemd-user-sessions.service
Conflicts=phosh.service
[Service]
User=droidian
PAMName=login
WorkingDirectory=/home/droidian
Environment=XDG_RUNTIME_DIR=/run/user/{account.pw_uid}
Environment=XDG_SESSION_TYPE=wayland
Environment=XDG_CURRENT_DESKTOP=Vulpes
Environment=LANG=C.UTF-8
Environment=WLR_BACKENDS=hwcomposer,libinput
Environment=EGL_PLATFORM=hwcomposer
Environment=WLR_HWC_SKIP_VERSION_CHECK=1
{isolation}ExecStart={'/usr/bin/dbus-run-session -- ' if root else ''}/usr/bin/phoc --no-xwayland --socket=tundra-0 -C {CONFIG} -E {json.dumps(child)}
{'' if root else 'ExecStartPost=+chvt 7'}
TTYPath=/dev/tty7
TTYReset=yes
TTYVHangup=yes
StandardInput=tty-fail
StandardOutput={'append:'+str(root/'opt/vulpes/logs/compositor.log') if root else 'journal'}
StandardError=inherit
KillMode=control-group
TimeoutStopSec=20
RuntimeMaxSec={args.seconds+30}
''')
for p in (DROPIN,CONFIG,SERVICE):p.chmod(0o644)
command('systemctl','daemon-reload')
command('systemctl','stop','phosh.service')
command('systemctl','start',UNIT)
if root:
command('chvt','7')
print('Own compositor started; Phosh will be restored automatically.',flush=True)
deadline=time.monotonic()+args.seconds
while time.monotonic()<deadline:
if command('systemctl','is-active',UNIT,check=False).returncode:
raise RuntimeError('Dedicated compositor stopped unexpectedly')
time.sleep(2)
except Exception as e:
result['error']=str(e)
raise
finally:
restore(result)
result['finishedAt']=time.time()
report=(root/'opt/vulpes' if root else product)/'logs/compositor-trial.json'
report.parent.mkdir(exist_ok=True)
report.write_text(json.dumps(result,indent=2)+'\n')
os.chown(report,account.pw_uid,account.pw_gid)
print('Restored Phosh:',result['restoredPhosh'],flush=True)
if __name__=='__main__':
main()

View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Export package-owned runtime files for an offline ARM64 chroot experiment.
This is a reference snapshot, not a Debian installation or a bootable rootfs.
Package scripts and host configuration are deliberately not imported.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
import tarfile
ROOT_PACKAGES = '''bash coreutils util-linux libc-bin python3 systemd systemd-sysv
udev dbus dbus-user-session libpam-systemd kbd phoc libhybris lxc lxc-android
halium-wrappers libgtk-3-0t64 libasound2t64 libdbus-glib-1-2 libxt6t64 libnss3
librsvg2-common shared-mime-info locales'''.split()
def installed_packages():
fields = ['binary:Package', 'Version', 'Architecture', 'db:Status-Abbrev',
'Depends', 'Pre-Depends', 'Provides', 'source:Package', 'source:Version']
output = subprocess.check_output(['dpkg-query', '-W', '-f',
'\t'.join('${'+f+'}' for f in fields)+'\n'], text=True)
return {p[0]: dict(zip(fields, p)) for line in output.splitlines()
if len(p := line.split('\t')) == len(fields) and p[3].startswith('ii')}
def dependency(token):
match = re.fullmatch(r'\s*([a-z0-9+.-]+)(?::([a-z0-9-]+))?'
r'\s*(?:\((<<|<=|=|>=|>>)\s*([^ )]+)\))?\s*', token)
if not match:
raise ValueError('Unsupported dependency: '+token)
return match.groups()
def closure(packages, roots):
providers = {}
for name, package in sorted(packages.items()):
base = name.split(':')[0]
providers.setdefault(base, []).append((name, package['Version']))
for token in package['Provides'].split(','):
if token.strip():
virtual, _, _, version = dependency(token)
providers.setdefault(virtual, []).append((name, version))
def resolve(expression):
for alternative in expression.split('|'):
name, arch, op, version = dependency(alternative)
candidates = sorted(providers.get(name, []),
key=lambda item: item[0].split(':')[0] != name)
for candidate, provided_version in candidates:
if arch not in (None, 'any', 'native', packages[candidate]['Architecture']):
continue
if op and (not provided_version or subprocess.run(
['dpkg', '--compare-versions', provided_version, op, version],
check=False).returncode):
continue
return candidate
raise ValueError('No installed provider: '+expression)
pending = [resolve(root) for root in roots]
result = {}
while pending:
name = pending.pop()
if name in result:
continue
package = dict(packages[name])
deps = [resolve(expr) for key in ('Pre-Depends', 'Depends')
for expr in package[key].split(',') if expr.strip()]
package['resolvedDependencies'] = deps
result[name] = package
pending.extend(deps)
return dict(sorted(result.items()))
def allowed(path):
parts = PurePosixPath(path).parts
return (len(parts) > 2 and parts[0] == '/' and '..' not in parts
and parts[1] in ('usr', 'bin', 'sbin', 'lib', 'lib64')
and parts[1:3] != ('usr', 'local'))
def export(output, packages):
output.mkdir(parents=True, exist_ok=False)
files = {}
for name in packages:
for path in subprocess.check_output(['dpkg-query', '-L', name], text=True).splitlines():
if allowed(path):
files.setdefault(path, []).append(name)
manifest = {'scope': __doc__, 'architecture': 'arm64', 'roots': ROOT_PACKAGES,
'packages': packages, 'files': {}, 'missing': []}
with tarfile.open(output/'runtime.tar', 'w') as tar:
for path, owners in sorted(files.items()):
source = Path(path)
if not source.exists() and not source.is_symlink():
manifest['missing'].append(path)
continue
if source.is_dir() and not source.is_symlink():
continue
info = tar.gettarinfo(path, arcname=path.lstrip('/'))
if not info.isfile() and not info.issym():
raise ValueError('Unexpected package file type: '+path)
info.uid = info.gid = 0
info.uname = info.gname = 'root'
info.mode &= ~0o6000 # No setuid/setgid programs in this reference snapshot.
record = {'packages': owners, 'mode': info.mode}
if info.isfile():
with source.open('rb') as stream:
record['sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
stream.seek(0)
tar.addfile(info, stream)
else:
record['symlink'] = info.linkname
tar.addfile(info)
manifest['files'][path] = record
with (output/'runtime.tar').open('rb') as stream:
manifest['archiveSha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
(output/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
print(json.dumps({'packages': len(packages), 'files': len(manifest['files']),
'missing': manifest['missing'], 'archiveSha256': manifest['archiveSha256']}))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--with-network', action='store_true',
help='Include NetworkManager and wpa_supplicant, without saved connections')
parser.add_argument('--with-hardware', action='store_true',
help='Include oFono, PulseAudio and gst-droid, without personal configuration')
args = parser.parse_args()
if os.geteuid() == 0:
parser.error('Run as the unprivileged device user')
if subprocess.check_output(['dpkg', '--print-architecture'], text=True).strip() != 'arm64':
parser.error('Expected the ARM64 reference device')
if args.with_network:
ROOT_PACKAGES.extend(['network-manager', 'wpasupplicant'])
if args.with_hardware:
ROOT_PACKAGES.extend(['ofono', 'pulseaudio', 'pulseaudio-utils',
'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl',
'gstreamer1.0-droid', 'bluebinder', 'libgbinder-radio',
'libgbinder-tools',
'libofonobinderpluginext', 'ofono-binder-plugin',
'ofono-configs-binder-common', 'ofono-scripts', 'ofono2mm',
'pulseaudio-config-droid', 'droidian-camera',
'qt5-cameraplugin-aal', 'libhybris'])
packages = closure(installed_packages(), ROOT_PACKAGES)
export(args.output, packages)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,39 @@
/* SPDX-License-Identifier: MPL-2.0
* Local GLVND adapter for Droidian libhybris b22d041 (Wayland only).
* That revision passes its wrapper EGLDisplay to Android eglGetConfigAttrib.
* All other entry points retain the vendor's implementation. The launcher
* must verify the exact vendor library hash before enabling this adapter.
*/
#define EGL_NO_X11
#include <glvnd/libeglabi.h>
extern void *dlopen(const char *, int);
extern void *dlsym(void *, const char *);
extern int strcmp(const char *, const char *);
static void *(*vendor_proc)(const char *);
static EGLBoolean (*vendor_config)(EGLDisplay, EGLConfig, EGLint, EGLint *);
static EGLBoolean config_attrib(EGLDisplay display, EGLConfig config,
EGLint attribute, EGLint *value) {
/* ws.h at b22d041: _EGLDisplay begins with the Android EGLDisplay dpy.
* GLVND has already checked that this display belongs to this vendor.
*/
EGLDisplay android_display = *(EGLDisplay *)display;
return vendor_config(android_display, config, attribute, value);
}
static void *get_proc(const char *name) {
if (!strcmp(name, "eglGetConfigAttrib")) return (void *)config_attrib;
return vendor_proc(name);
}
EGLBoolean __egl_Main(uint32_t version, const __EGLapiExports *exports,
__EGLvendorInfo *vendor, __EGLapiImports *imports) {
void *library = dlopen("libEGL_libhybris.so.0", 2);
if (!library) return EGL_FALSE;
EGLBoolean (*entry)(uint32_t, const __EGLapiExports *, __EGLvendorInfo *,
__EGLapiImports *) = dlsym(library, "__egl_Main");
if (!entry || !entry(version, exports, vendor, imports)) return EGL_FALSE;
vendor_proc = imports->getProcAddress;
vendor_config = vendor_proc("eglGetConfigAttrib");
if (!vendor_config) return EGL_FALSE;
imports->getProcAddress = get_proc;
return EGL_TRUE;
}

View file

@ -0,0 +1,11 @@
--- a/hybris/egl/egl.c
+++ b/hybris/egl/egl.c
@@ -638,7 +638,7 @@
EGLBoolean ret;
HYBRIS_TRACE_BEGIN("hybris-egl", "eglGetConfigAttrib", "");
HYBRIS_DLSYSM(egl, &_eglGetConfigAttrib, "eglGetConfigAttrib");
- ret = (*_eglGetConfigAttrib)(dpy, config, attribute, value);
+ ret = (*_eglGetConfigAttrib)(hybris_egl_get_real_display(dpy), config, attribute, value);
ws_getConfigAttrib(&dpy, &config, &attribute, value);
HYBRIS_TRACE_END("hybris-egl", "eglGetConfigAttrib", "");
return ret;

View file

@ -0,0 +1,6 @@
{
"vendorLibrary": "/usr/lib/aarch64-linux-gnu/libEGL_libhybris.so.0",
"vendorSha256": "e6e949e478f8f96718afe07e4e0c50f524afb6f587aed89304eb7d083ba3dcb5",
"sourceRevision": "b22d041aaf97c5e2bf7ea9a4281fdf9b73edd805",
"upstreamSource": "https://github.com/droidian/libhybris/blob/b22d041aaf97c5e2bf7ea9a4281fdf9b73edd805/hybris/egl/egl.c"
}

View file

@ -0,0 +1,18 @@
#!/bin/sh
# SPDX-License-Identifier: MPL-2.0
# Run inside the staged root before starting its graphical session.
set -eu
/sbin/ldconfig
/usr/lib/aarch64-linux-gnu/glib-2.0/glib-compile-schemas /usr/share/glib-2.0/schemas
/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders --update-cache
/usr/bin/update-mime-database /usr/share/mime
/usr/bin/python3 - <<'PY'
import gzip
from pathlib import Path
for name in ('UTF-8', 'ANSI_X3.4-1968'):
compressed = Path('/usr/share/i18n/charmaps')/(name+'.gz')
if compressed.is_file():
compressed.with_suffix('').write_bytes(gzip.decompress(compressed.read_bytes()))
PY
/usr/bin/localedef --no-archive -i C -f UTF-8 C.UTF-8
test -s /usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders.cache

View file

@ -0,0 +1,29 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read hardware service topology without SIM identifiers, messages or network secrets."""
import json
import subprocess
import time
from pathlib import Path
def main():
commands={
'services':['systemctl','is-active','NetworkManager','ModemManager','ofono','phosh'],
'networkDevices':['nmcli','-t','-f','DEVICE,TYPE,STATE','device','status'],
'modemObjects':['busctl','--system','tree','org.freedesktop.ModemManager1'],
'ofonoObjects':['busctl','--system','tree','org.ofono'],
'audioPackages':['dpkg-query','-W','-f=${binary:Package}\t${Version}\n','pulseaudio','pulseaudio-modules-droid-hidl','callaudiod','ofono-binder-plugin','modemmanager','droidian-camera'],
}
report={'recordedAtUnix':time.time(),'scope':'Read-only service topology; no SIM identifiers, message content or saved connection profiles','commands':{}}
for name,command in commands.items():
try:
p=subprocess.run(command,capture_output=True,text=True,timeout=15)
report['commands'][name]={'returncode':p.returncode,'stdout':p.stdout,'stderr':p.stderr}
except (OSError,subprocess.TimeoutExpired) as error:
report['commands'][name]={'error':str(error)}
report['cameraNodes']=[p.name for p in Path('/dev').glob('video*')]
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the installed display/container adaptation; exclude user data and credentials."""
import datetime
import hashlib
import json
from pathlib import Path
import subprocess
def run(args):
result=subprocess.run(args,capture_output=True,text=True,timeout=30)
return {'command':args,'returncode':result.returncode,'stdout':result.stdout,'stderr':result.stderr}
def main():
device=run(['getprop','ro.product.device'])
if device['stdout'].strip()!='sargo':
raise RuntimeError('Expected Sargo')
report={'recordedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'device':'sargo','readOnly':True,'files':{},'commands':{}}
commands={
'kernel':['uname','-a'],
'units':['systemctl','cat','android-mount.service','lxc@android.service',
'android-service@hwcomposer.service','phosh.service'],
'compositor':['phoc','--version'],
'packages':['dpkg-query','-W','-f','${binary:Package}\t${Version}\t${Architecture}\n'],
'phocLibraries':['ldd','/usr/bin/phoc'],
'eglLibraries':['ldd','/usr/lib/aarch64-linux-gnu/libEGL_libhybris.so.0'],
'mounts':['findmnt','--json','-o','SOURCE,TARGET,FSTYPE,OPTIONS'],
}
for name,command in commands.items():report['commands'][name]=run(command)
paths=['/var/lib/lxc/android/config','/var/lib/lxc/android/pre-start.sh',
'/usr/lib/lxc-android/mount-android','/usr/lib/lxc-android/lxc-android-notify',
'/usr/lib/lxc-android/lxc-android-stop','/usr/sbin/mount-android.sh',
'/usr/lib/halium-wrappers/android-service.sh','/etc/phosh/phoc.ini',
'/usr/share/phosh/phoc.ini','/etc/deviceinfo/default.yaml',
'/etc/pam.d/phosh','/etc/pam.d/login',
'/usr/share/doc/android-system-google-sargo/copyright',
'/usr/share/doc/libhybris/copyright','/usr/share/doc/phoc/copyright',
'/usr/share/doc/lxc-android/copyright']
paths += [str(p) for p in Path('/etc/deviceinfo/devices').glob('*.yaml')]
for path in paths:
p=Path(path)
try:
data=p.read_bytes()
report['files'][path]={'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),
'text':data.decode(),'owner':run(['dpkg-query','-S',path])['stdout'].strip()}
except (OSError,UnicodeError) as error:
report['files'][path]={'error':str(error)}
image=Path('/var/lib/lxc/android/android-rootfs.img')
with image.open('rb') as stream:
report['androidSystemImage']={'path':str(image),'bytes':image.stat().st_size,
'sha256':hashlib.file_digest(stream,'sha256').hexdigest()}
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Install the common product and generated session configuration in a staged root."""
import argparse
import os
from pathlib import Path
import shutil
def prepare(root, product, uid):
if not (root.parent/(root.name+'-stage.json')).is_file():
raise ValueError('Expected a staged runtime reference')
target = root/'opt/vulpes'
for name in ('assets','host','services','adapters','overrides','gaia','tools',
'tests','engines','trial','engine-lock.json','release.json'):
source, destination = product/name, target/name
if source.is_dir():
shutil.copytree(source,destination,dirs_exist_ok=True,symlinks=True,
ignore=shutil.ignore_patterns('__pycache__'))
else:
shutil.copy2(source,destination)
(target/'logs').mkdir(exist_ok=True)
(root/'home/droidian').mkdir(exist_ok=True)
(root/'etc/passwd').write_text(f'root:x:0:0:root:/root:/bin/bash\ndroidian:x:{uid}:{uid}:Vulpes trial:/home/droidian:/bin/bash\n')
(root/'etc/group').write_text(f'root:x:0:\ndroidian:x:{uid}:\n')
for name in ('system','vendor'):
path=root/name
if not path.is_symlink() and not path.exists():path.symlink_to('android/'+name)
# D-Bus uses /usr/share/dbus-1/*.conf. Do not alias those files into /etc:
# the packaged defaults include the optional /etc override themselves.
(root/'etc/dbus-1').mkdir(exist_ok=True)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root',type=Path,required=True)
parser.add_argument('--product',type=Path,required=True)
args=parser.parse_args()
if os.getuid()==0:parser.error('Prepare as the unprivileged trial user')
prepare(args.root.resolve(),args.product.resolve(),os.getuid())
if __name__=='__main__':main()

View file

@ -0,0 +1,614 @@
// SPDX-License-Identifier: MPL-2.0
// Runs only in the isolated Droidian trial, without a remote debugger.
import {setTimeout, setInterval, clearInterval} from 'resource://gre/modules/Timer.sys.mjs';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export async function run(win) {
dump('TUNDRA_DROIDIAN_PROBE started\n');
const interactive = Services.env.get('TUNDRA_DIAGNOSTICS') === '1';
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
const report = {passed:false, mode:Services.env.get('TUNDRA_HEADLESS')==='1'?'headless':'wayland',
ownCompositor:Services.env.get('TUNDRA_OWN_COMPOSITOR')==='1', checks:{}, scope:interactive?'interactive':'startup-only', engine:{
version:Services.appinfo.platformVersion, buildID:Services.appinfo.appBuildID,
ABI:Services.appinfo.XPCOMABI, OS:Services.appinfo.OS}};
report.bootId = Services.env.get('TUNDRA_BOOT_ID');
const listener={observe(message) {
const text=message.message || String(message);
if(text.includes('.localhost:8765') || text.includes('resource://vulpes/'))
dump('TUNDRA_CONSOLE '+text+'\n');
}};
Services.console.registerListener(listener);
if (Services.env.get('VULPES_TUNDRA') === '1') {
let checking = false;
const networkTimer = setInterval(async () => {
if (checking) return;
checking = true;
try {
const state = await IOUtils.readJSON('/run/tundra-hardware/status.json');
if (state.wifi?.state !== 'connected') return;
clearInterval(networkTimer);
const response = await win.fetch('https://example.org/', {
signal: win.AbortSignal.timeout(20000), cache: 'no-store',
});
const body = await response.text();
await IOUtils.writeJSON(`${root}/logs/network-gecko.json`, {
passed: response.ok && body.includes('Example Domain'),
status: response.status, gecko: Services.appinfo.platformVersion,
url: 'https://example.org/', certificateChecksDisabled: false,
});
} catch (error) {
await IOUtils.writeJSON(`${root}/logs/network-gecko.json`, {passed:false,error:String(error)});
} finally { checking = false; }
}, 5000);
win.addEventListener('unload', () => clearInterval(networkTimer), {once:true});
}
function contextState(context) {
return context ? {id:context.id,url:context.currentWindowGlobal?.documentURI.spec,
children:Array.from(context.children,contextState)} : null;
}
function hostState() {
const browser=win.document.querySelector('browser');
return {ready:win.hostReady,visibility:win.document.visibilityState,
fission:Services.appinfo.fissionAutostart,
fissionPref:Services.prefs.getBoolPref('fission.autostart',false),
proxy:Services.prefs.getIntPref('network.proxy.type',-1),
browser:browser ? {src:browser.getAttribute('src'),uri:browser.currentURI?.spec,
loading:browser.webProgress?.isLoadingDocument,
remote:browser.getAttribute('remote'),remoteType:browser.remoteType,
context:contextState(browser.browsingContext),
active:browser.docShellIsActive,renderLayers:browser.renderLayers} : null};
}
let previousState;
const trace=setInterval(()=>{
const state=JSON.stringify(hostState());
if(state!==previousState) {dump('TUNDRA_HOST_STATE '+state+'\n');previousState=state;}
},5000);
win.fetch('http://system.localhost:8765/index.html', {signal:win.AbortSignal.timeout(10000)})
.then(async response=>dump(`TUNDRA_HTTP ${response.status} ${(await response.text()).length}\n`))
.catch(error=>dump('TUNDRA_HTTP_ERROR '+error+' offline='+Services.io.offline+'\n'));
async function evaluate(app, code, message = 'Evaluate') {
function find(context) {
if(context.currentWindowGlobal?.documentURI.spec.startsWith(`http://${app}.localhost:8765/`)) return context;
for(const child of context.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 Promise.race([
child.currentWindowGlobal.getActor('VulpesProbe').sendQuery(message,code),
sleep(30000).then(()=>{throw new Error('Content actor did not answer within 30 seconds');})
]);
}
async function wait(app, code) {
dump(`TUNDRA_DROIDIAN_PROBE waiting ${app}: ${code}\n`);
let last;
const deadline=Date.now()+90000;
while(Date.now()<deadline) {
try {last=await evaluate(app,code); if(last) return last;} catch(e) {last=String(e);}
await sleep(500);
}
throw new Error(`${app}: timeout: ${last}`);
}
async function tap(app, selector) {
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
await wait(app, `const e=document.querySelector(${JSON.stringify(selector)});if(!e)return false;const r=e.getBoundingClientRect();return r.width>0 && r.height>0 && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));`);
await sleep(600);
const point = await evaluate(app, `const e=document.querySelector(${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};`);
await evaluate(app,point,'Tap');
await sleep(350);
}
try {
const lock=await IOUtils.readJSON(`${root}/engine-lock.json`);
if(report.engine.version!==lock.version || !report.engine.ABI.startsWith('aarch64'))
throw new Error('Unexpected engine or architecture');
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.visible=await wait('system',"return document.visibilityState==='visible';");
report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || getComputedStyle(logo).opacity==='0';");
if (Services.env.get('TUNDRA_PERSISTENT') === '1') {
const token = Services.uuid.generateUUID().toString();
report.persistence = await evaluate('system', `
const key='vulpes.tundra.persistence-proof';
const previous=await VulpesCompat.call('settings.get',{key});
if (!previous.found) await VulpesCompat.call('settings.set',{values:{[key]:${JSON.stringify(token)}}});
const current=await VulpesCompat.call('settings.get',{key});
return {restored:previous.found,value:current.value,found:current.found};
`);
report.checks.settingsStorage = report.persistence.found && typeof report.persistence.value === 'string';
}
if (interactive) {
for(const app of ['settings','clock']) {
await evaluate('system',`await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
report.checks[app]=await wait(app,"return document.readyState==='complete' && document.body.innerText.length>20;");
if (app === 'settings') {
report.checks.noDebugSettings = await evaluate('settings', "return !document.getElementById('vulpes-open-settings') && !document.getElementById('vulpes-test-notification');");
}
}
win.document.getElementById('home')?.click();
report.checks.returnHome=await wait('system',"return !!document.querySelector('.appWindow.active[transition-state=opened] iframe[src*=\"homescreen.localhost\"]');");
report.input=await evaluate('homescreen',"return {maxTouchPoints:navigator.maxTouchPoints,touchEvents:'ontouchstart' in window,coarse:matchMedia('(pointer: coarse)').matches};");
report.nativeTouch=Services.ppmm.sharedData.get('vulpes:native-touch')===true;
report.frameTiming=await evaluate('homescreen',`return await new Promise(resolve=>{
const target=Array.from(document.querySelectorAll('*')).find(el=>el.scrollHeight>el.clientHeight+100 && ['auto','scroll'].includes(getComputedStyle(el).overflowY));
const original=target?.scrollTop || 0, samples=[]; let start,previous;
function frame(now) {
if(start===undefined)start=now;
if(previous!==undefined)samples.push(now-previous);
previous=now;
if(target)target.scrollTop=original+Math.min(300,(now-start)*.15);
if(now-start<4000){requestAnimationFrame(frame);return;}
if(target)target.scrollTop=original;
samples.sort((a,b)=>a-b);
resolve({durationMs:now-start,frames:samples.length,fps:samples.length*1000/(now-start),medianMs:samples[Math.floor(samples.length*.5)],p95Ms:samples[Math.floor(samples.length*.95)],scrollTarget:target?.id||target?.tagName||null});
}
requestAnimationFrame(frame);
});`);
report.fonts = await evaluate('homescreen', `await document.fonts.ready; return {loaded:Array.from(document.fonts,f=>({family:f.family,weight:f.weight,status:f.status})),icon:document.querySelector('gaia-app-icon')?.outerHTML,shadow:document.querySelector('gaia-app-icon')?.shadowRoot?.innerHTML,images:Array.from(document.querySelectorAll('gaia-app-icon')).slice(0,12).map(i=>{const img=i.shadowRoot?.querySelector('img');const title=i.shadowRoot?.querySelector('#subtitle');return {id:i.dataset.identifier,src:img?.src,complete:img?.complete,naturalWidth:img?.naturalWidth,width:img?.clientWidth,height:img?.clientHeight,font:title&&getComputedStyle(title).font};}),styles:Array.from(document.styleSheets,s=>s.href)};`);
report.viewport={width:win.innerWidth,height:win.innerHeight,devicePixelRatio:win.devicePixelRatio};
report.checks.homeIcons = await wait('homescreen', `const icons=Array.from(document.querySelectorAll('gaia-app-icon')).slice(0,12);return icons.length===12 && icons.every(i=>{const image=i.shadowRoot?.querySelector('img');return image?.complete && image.naturalWidth>0 && image.clientWidth>0;});`);
report.checks.firaLoaded = report.fonts.loaded.some(f=>f.family==='FiraSans' && f.weight==='400' && f.status==='loaded');
const surface=()=>{
const button=win.document.getElementById('home'), style=win.getComputedStyle(button);
return {home:win.document.documentElement.hasAttribute('home-active'),
bottom:win.document.documentElement.hasAttribute('home-at-bottom'),
background:style.backgroundImage,color:style.backgroundColor};
};
async function waitSurface(test) {
for(let i=0;i<100;i++) {const s=surface();if(test(s))return s;await sleep(100);}
throw Error('Unexpected Home surface: '+JSON.stringify(surface()));
}
await evaluate('homescreen', `document.querySelector('#apps-panel > .scrollable').scrollTop=0;return true;`);
report.navigation={top:await waitSurface(s=>s.home && !s.bottom && s.background.includes('linear-gradient'))};
win.InspectorUtils.addPseudoClassLock(win.document.getElementById('home'),':active');
report.navigation.pressed=surface();
win.InspectorUtils.removePseudoClassLock(win.document.getElementById('home'),':active');
await evaluate('homescreen', `const p=document.querySelector('#apps-panel > .scrollable');p.scrollTop=p.scrollHeight;return true;`);
report.navigation.bottom=await waitSurface(s=>s.bottom && s.background==='none' && s.color==='rgba(0, 0, 0, 0)');
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;`);
report.navigation.application=await waitSurface(s=>!s.home && s.background==='none' && s.color==='rgb(18, 35, 50)');
win.document.getElementById('home').click();
await waitSurface(s=>s.home);
await evaluate('homescreen', `document.querySelector('#apps-panel > .scrollable').scrollTop=0;return true;`);
report.navigation.returnHome=await waitSurface(s=>s.home && !s.bottom && s.background.includes('linear-gradient'));
report.checks.navigation=report.navigation.top.background===report.navigation.pressed.background;
if (Services.env.get('VULPES_TUNDRA') === '1') {
report.hardware = await evaluate('system', "return await VulpesCompat.call('platform.snapshot', {});");
report.capabilities = await evaluate('system', "return await VulpesCompat.call('platform.capabilities', {});");
report.checks.nativeIdentity = report.capabilities.platform === 'tundra' && report.capabilities.adapter.device === 'sargo';
report.checks.batteryRead = report.hardware.available && Number.isFinite(report.hardware.battery?.level);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;`);
await wait('settings', "return !!document.querySelector('a[href=\"#battery\"]');");
await evaluate('settings', "document.querySelector('a[href=\"#battery\"]').click();return true;");
try {
report.checks.hardwarePanel = await wait('settings', "const d=document.getElementById('vulpes-hardware-panel');return !!d?.open && d.innerText.includes('%');");
await evaluate('settings', "document.getElementById('vulpes-hardware-panel').close();return true;");
} catch(error) {
report.hardwarePanelNote = String(error);
report.checks.hardwarePanel = null;
}
win.document.getElementById('home').click();
await waitSurface(s=>s.home);
}
} else {
// Observe the normal boot without opening apps or moving the home screen.
report.startup = win.vulpesStartup;
report.input = await evaluate('homescreen', "return {maxTouchPoints:navigator.maxTouchPoints,touchEvents:'ontouchstart' in window};");
report.nativeTouch = Services.ppmm.sharedData.get('vulpes:native-touch') === true;
report.samples = [];
for (let i = 0; i < 5; i++) {
report.samples.push(await evaluate('system', `return {
app:document.querySelector('.appWindow.active iframe')?.src,
locked:!!window.lockScreen?.locked && document.getElementById('screen').classList.contains('locked'),
width:innerWidth,height:innerHeight
};`));
await sleep(1000);
}
report.checks.idleStartSurface = report.samples.every(s => s?.locked || s?.app?.startsWith('http://homescreen.localhost:8765/'));
report.checks.stableViewport = report.samples.every(s => s?.width === report.samples[0]?.width && s?.height === report.samples[0]?.height);
report.window = {fullscreen:win.fullScreen, hidechrome:win.document.documentElement.getAttribute('hidechrome')};
if (Services.env.get('VULPES_TUNDRA') === '1') {
report.hardware = await evaluate('system', "return await VulpesCompat.call('platform.snapshot', {});");
report.capabilities = await evaluate('system', "return await VulpesCompat.call('platform.capabilities', {});");
report.checks.nativeIdentity = report.capabilities.platform === 'tundra';
report.checks.batteryRead = report.hardware.available && Number.isFinite(report.hardware.battery?.level);
report.checks.callScreenReady = await evaluate('system',
"return !!navigator.mozTelephony && !!document.querySelector('.callscreenWindow') && Array.isArray(await VulpesCompat.call('platform.calls', {}));");
try {
const { tundraRadio } = ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
const state = await tundraRadio('status');
const modem = state.modem?.[0] || {};
const registration = state.registration?.[0] || {};
report.radio = {powered:modem.Powered?.data, online:modem.Online?.data,
state:registration.Status?.data, technology:registration.Technology?.data};
} catch (error) { report.radio = {error:String(error)}; }
}
}
// Fixed diagnostics can be requested again over the private maintenance SSH.
// Markers never contain scripts or arbitrary commands.
async function requestedUiChecks() {
const cameraMarker = `${root}/logs/test-camera`;
if (await IOUtils.exists(cameraMarker)) {
await IOUtils.remove(cameraMarker);
const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
await wait('system', "return !Service.query('locked');");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
// Gaia remembers the selected lens; establish a known starting state.
await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.gaia = await evaluate('camera', `
const video=document.querySelector('video');
const camera=app.camera.mozCamera;
window.__cameraBeforeSwitch=camera;
const capabilities=camera.capabilities;
let focus=null;
if(capabilities.maxFocusAreas>0) {
camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
await camera.autoFocus();focus=true;
}
if(capabilities.zoomRatios.length>1) {
camera.zoom=2;
await new Promise(r=>setTimeout(r,700));
}
const testedZoom=camera.zoom;
camera.zoom=1;
const photo=await camera.takePicture();
const bitmap=await createImageBitmap(photo);
const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
picture,focus,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
`);
cameraReport.performance = await evaluate('camera', `
const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
const start=performance.now();await new Promise(r=>setTimeout(r,4000));
const elapsed=performance.now()-start;
const hud=document.querySelector('.hud');
const button=document.querySelector('.js-camera');
return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
`);
await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.switched = await evaluate('camera', `
const photo=await app.camera.mozCamera.takePicture();
window.__cameraBeforeHome=app.camera.mozCamera;
return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
`);
cameraReport.roundTrips = [];
for (let i=0; i<4; i++) {
await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
await tap('camera', '.js-camera');
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.roundTrips.push(await evaluate('camera', `
const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
await new Promise(r=>setTimeout(r,700));
return {camera:app.settings.cameras.selected('key'),
frames:camera.vulpesTiming.frames-before,
previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
`));
}
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
// Test Gaia's Home action, independent of a held pointer on the shell button.
cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
cameraReport.hidden = await wait('camera', 'return document.hidden;');
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
cameraReport.passed=cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
} catch(error) { cameraReport.error=String(error); }
finally { win.document.getElementById('home')?.click(); }
await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
}
const smsMarker = `${root}/logs/test-sms-display`;
if (await IOUtils.exists(smsMarker)) {
await IOUtils.remove(smsMarker);
const smsReport = {bootId:report.bootId, passed:false, sent:false};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
smsReport.threads = await evaluate('sms', `
const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
window.__smsReviewThreads=threads.map(t=>t.id);
return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
`);
smsReport.conversations=[];
for(let i=0;i<Math.min(smsReport.threads.count,5);i++) {
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
await sleep(1500);
smsReport.conversations.push(await evaluate('sms', `
const nodes=[...document.querySelectorAll('#messages-container .message')];
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.isFinite(+e.querySelector('time')?.dataset.time)),
nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
errorCount:window.__vulpesDiagnostics?.length||0};
`));
}
smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
} catch(error) {smsReport.error=String(error);}
finally {win.document.getElementById('home')?.click();}
await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
}
const inputMarker = `${root}/logs/test-keyboard`;
if (await IOUtils.exists(inputMarker)) {
await IOUtils.remove(inputMarker);
const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
try {
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1500);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
await tap('sms','.recipient[contenteditable]');
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
await tap('keyboard','button[aria-label="2"]');
await tap('keyboard','button[aria-label="3"]');
inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
await tap('sms','#messages-input');
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
await tap('keyboard','button[aria-label="a"]');
await sleep(2000);
inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
if(!inputReport.messageTyped) {
try {
await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
} catch(error) {inputReport.directKeyError=String(error);}
}
inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
for (const face of inputReport.fonts) delete face.samples;
} catch(error) {inputReport.error=String(error);}
finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
}
const sendMarker=`${root}/logs/test-send-sms.json`;
if(await IOUtils.exists(sendMarker)) {
const request=await IOUtils.readJSON(sendMarker);
await IOUtils.remove(sendMarker);
const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
try {
if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
const state=await tundraRadio('status');
if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1000);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms','return !!window.Navigation;');
await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
await tap('sms','.recipient[contenteditable]');
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
await tap('sms','#messages-input');
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
const body='Vulpes OS - test SMS '+new Date().toISOString();
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
await evaluate('sms',`if(!Navigation.isCurrentPanel('composer') ||
JSON.stringify(ConversationView.recipients.numbers)!==JSON.stringify([${JSON.stringify(request.recipient)}]) ||
Compose.getText()!==${JSON.stringify(body)})throw Error('TEST_MESSAGE_MISMATCH');
document.querySelector('#messages-send-button').click();return true;`);
result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
const cursor=navigator.mozMobileMessage.getMessages(null,true);
cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
cursor.onerror=()=>reject(cursor.error);
});`);
} catch(error) {result.error=String(error);}
finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
}
const notificationMarker = `${root}/logs/test-notifications`;
if (await IOUtils.exists(notificationMarker)) {
await IOUtils.remove(notificationMarker);
const result = {bootId:report.bootId,passed:false,silent:true};
let id;
try {
await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms', 'return typeof Notification.get === "function";');
await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
});`);
const selector = `[data-notification-id="${id}"]`;
result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
result.passed = result.tray && result.lockscreen && result.removed;
} catch(error) { result.error=String(error); }
finally {
if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
win.document.getElementById('home').click();
}
await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
}
}
await requestedUiChecks();
let uiCheckRunning=false;
const uiCheckTimer=setInterval(async()=>{
if(uiCheckRunning) return;
uiCheckRunning=true;
try {
const reloadMarker=`${root}/logs/test-reload-gaia`;
if(await IOUtils.exists(reloadMarker)) {
await IOUtils.remove(reloadMarker);
win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
await sleep(1500);
}
await requestedUiChecks();
} catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
finally {uiCheckRunning=false;}
},3000);
win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
const controlsMarker = `${root}/logs/test-controls`;
if (await IOUtils.exists(controlsMarker)) {
await IOUtils.remove(controlsMarker);
const controls = {bootId:report.bootId};
const press = async key => {
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'${key}-button-press'}})); return true;`);
await sleep(120);
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'${key}-button-release'}})); return true;`);
await sleep(1000);
};
const brightness = async () => (await IOUtils.readUTF8('/sys/class/backlight/panel0-backlight/brightness')).trim();
controls.beforeBrightness = await brightness();
try {
await press('sleep'); controls.offBrightness = await brightness();
await press('sleep'); controls.onBrightness = await brightness();
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'sleep-button-press'}}));return true;`);
await sleep(1000);
controls.powerMenu = await evaluate('system', `return {visible:document.getElementById('sleep-menu').classList.contains('visible'),text:document.getElementById('sleep-menu').innerText};`);
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'sleep-button-release'}}));dispatchEvent(new Event('home'));return true;`);
await press('volume-up');
controls.volume = await evaluate('system', `return {visible:document.getElementById('volume').classList.contains('visible'),channel:document.getElementById('volume').dataset.channel};`);
await press('volume-down');
controls.battery = await evaluate('system', `return {level:navigator.battery?.level,charging:navigator.battery?.charging,icon:document.getElementById('statusbar-battery')?.outerHTML};`);
} catch (error) { controls.error = String(error); }
finally { await evaluate('system', 'navigator.mozPower.screenEnabled=true; return true;'); }
const restoration = {bootId:report.bootId};
try {
restoration.date = await evaluate('system', "return !!document.querySelector('#statusbar-operator')?.textContent;");
restoration.lockIcons = await evaluate('system', "return getComputedStyle(document.querySelector('#lockscreen-area-unlock > div')).backgroundImage.includes('lockscreen_unlock.png');");
const content = win.document.getElementById('screen').getBoundingClientRect();
const home = win.document.getElementById('home').getBoundingClientRect();
restoration.navigationReserved = content.bottom <= home.top + 1;
await evaluate('system', "lockScreen.unlock(true);return true;");
await sleep(1000);
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
await wait('settings', "return document.readyState==='complete' && typeof require==='function' && require.defined('modules/settings_panel');");
restoration.settings = await wait('settings', "return !document.getElementById('vulpes-platform-settings') && !!document.querySelector('#root a[href=\"#wifi\"]');");
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('battery'));return true;");
restoration.batteryLabel = await wait('settings', "const t=document.getElementById('battery-level')?.textContent;return !!t && /[0-9]/.test(t) && !t.includes('{{');");
restoration.wifiAPI = await evaluate('settings', "return !!navigator.mozWifiManager;");
await evaluate('system', "await VulpesCompat.call('settings.set',{values:{'wifi.enabled':true}});return true;");
await wait('settings', "return navigator.mozWifiManager.enabled;");
if (restoration.wifiAPI) {
controls.wifiTransport = await evaluate('settings', "try {const r=await VulpesCompat.call('platform.wifi',{operation:'scan'});return {ok:true,count:r.networks.length,enabled:r.enabled};} catch(e) {return {ok:false,name:e.name,message:e.message};}");
const networks = await evaluate('settings', "return (await navigator.mozWifiManager.getNetworks()).length;");
restoration.wifiScan = Number.isInteger(networks);
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('wifi'));return true;");
restoration.wifiPanel = await wait('settings', "return !!document.querySelector('#wifi .wifi-availableNetworks') && !document.querySelector('#wifi [data-vulpes-unavailable]');");
restoration.wifiResults = await wait('settings', "const list=document.querySelector('#wifi .wifi-availableNetworks');return list?.dataset.state==='ready' && list.querySelectorAll('li:not([data-state])').length>0;");
restoration.wifiNoSpinner = await evaluate('settings', "return !document.querySelector('#wifi .wifi-availableNetworks [data-vulpes-unavailable]');");
}
restoration.passed = Object.entries(restoration).filter(([key])=>key!=='bootId').every(([,value])=>value===true);
} catch (error) { restoration.passed=false; restoration.error=String(error); }
finally {
win.document.getElementById('home').click();
await evaluate('system', "if (lockScreen.enabled) lockScreen.lock(true);return true;");
}
// Exercise the original quick-settings control, not only its broker.
try {
const {tundraWifi}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
await evaluate('system', "lockScreen.unlock(true);ScreenManager.turnScreenOn(true);return true;");
await sleep(1000);
const before=(await tundraWifi({operation:'status'})).enabled;
const toggle=async wanted=>{
await evaluate('system', "document.getElementById('quick-settings-wifi').click();return true;");
const deadline=Date.now()+10000;
while(Date.now()<deadline) {
if((await tundraWifi({operation:'status'})).enabled===wanted) return true;
await sleep(200);
}
throw Error('Quick Wi-Fi control did not change radio state');
};
try {await toggle(!before);controls.wifiToggle=true;}
finally {
if((await tundraWifi({operation:'status'})).enabled!==before) await toggle(before);
}
win.document.getElementById('home').click();await sleep(1000);
} catch(error) {controls.quickControlsError=String(error);}
controls.restoration=restoration;
const brightnessCheck = {passed:false};
try {
await evaluate('system', "lockScreen.unlock(true);ScreenManager.turnScreenOn(true);return true;");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('display'));return true;");
await wait('settings', "return !!document.querySelector('#display.current .brightness-manual input');");
const old = await evaluate('system', "return ScreenManager._userBrightness;");
const set = async value => {
await evaluate('settings', `const input=document.querySelector('#display .brightness-manual input');input.value=${value};input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));return true;`);
const expected = Math.round(value * Number(await IOUtils.readUTF8('/sys/class/backlight/panel0-backlight/max_brightness')));
const start=Date.now();
let measured;
do {
await sleep(100);
measured=Number(await IOUtils.readUTF8('/sys/class/backlight/panel0-backlight/brightness'));
if (Math.abs(measured-expected)<=3) {brightnessCheck.lastSettleMs=Date.now()-start;return measured;}
} while (Date.now()-start<5000);
throw Error(`Backlight did not reach ${expected}: ${measured}`);
};
try {brightnessCheck.low=await set(0.35);brightnessCheck.high=await set(0.8);brightnessCheck.passed=brightnessCheck.low>0 && brightnessCheck.high>brightnessCheck.low;}
finally {await set(Number.isFinite(old)?old:0.8);}
} catch(error) {brightnessCheck.error=String(error);}
finally {win.document.getElementById('home').click();}
controls.brightness=brightnessCheck;
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls);
}
report.layout=await evaluate('homescreen',"return {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,rootFont:getComputedStyle(document.documentElement).fontSize,bodyFont:getComputedStyle(document.body).fontFamily,zoom:visualViewport.scale,rootWidth:document.documentElement.scrollWidth};");
report.textFonts = await evaluate('homescreen', `
await document.fonts.ready;
return Array.from(document.fonts, f => ({family:f.family,weight:f.weight,style:f.style,status:f.status}));
`);
report.checks.firaLoaded = report.textFonts.some(f => f.family === 'FiraSans' && f.weight === '400' && f.status === 'loaded');
try {
report.renderedFonts = {
system: await evaluate('system', null, 'RenderedFonts'),
homescreen: await evaluate('homescreen', null, 'RenderedFonts'),
};
} catch (error) { report.renderedFontsError = String(error); }
try {
report.graphics=Cc['@mozilla.org/gfx/info;1'].getService(Ci.nsIGfxInfo).getFeatures();
} catch(error) {report.graphicsError=String(error);}
await sleep(1500);
try {
const bitmap=await win.browsingContext.currentWindowGlobal.drawSnapshot(
new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
canvas.width=bitmap.width;canvas.height=bitmap.height;
canvas.getContext('2d').drawImage(bitmap,0,0);bitmap.close();
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
await IOUtils.write(`${root}/logs/home.png`,new Uint8Array(await blob.arrayBuffer()));
report.screenshot='home.png';
} catch(error) {report.screenshotError=String(error);}
report.passed = Object.values(report.checks).every(value => value === true);
} catch(error) {
report.error=String(error)+'\n'+error.stack;
report.contexts=contextState(win.browsingContext);
report.host=hostState();
try {report.systemState=await evaluate('system',"return {ready:document.readyState,visibility:document.visibilityState,state:document.body?.getAttribute('ready-state'),text:document.body?.innerText.slice(0,1000)};");}
catch(stateError) {report.stateError=String(stateError);}
}
Services.console.unregisterListener(listener);
clearInterval(trace);
await IOUtils.writeJSON(`${root}/logs/test.json`,report);
dump('TUNDRA_DROIDIAN_TEST '+JSON.stringify(report)+'\n');
}

208
tundra/droidian/session.py Normal file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run an isolated Vulpes window inside the existing Droidian Wayland session."""
import argparse
import fcntl
import importlib.util
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
def drop_capabilities():
"""The compositor may inherit hardware capabilities; Gaia must not keep them."""
import ctypes
libc=ctypes.CDLL(None,use_errno=True)
if libc.prctl(47,4,0,0,0): # PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL
raise OSError(ctypes.get_errno(),'clear ambient capabilities')
class Header(ctypes.Structure):
_fields_=[('version',ctypes.c_uint32),('pid',ctypes.c_int)]
class Data(ctypes.Structure):
_fields_=[('effective',ctypes.c_uint32),('permitted',ctypes.c_uint32),('inheritable',ctypes.c_uint32)]
if libc.capset(ctypes.byref(Header(0x20080522,0)),(Data*2)()):
raise OSError(ctypes.get_errno(),'drop process capabilities')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--hybris-egl', action='store_true', help='Use the hash-pinned local libhybris EGL correction')
parser.add_argument('--software', action='store_true', help='Use software WebRender for EGL driver diagnostics')
parser.add_argument('--headless', action='store_true', help='Test Gecko/Gaia without the locked physical display')
parser.add_argument('--no-forkserver', action='store_true', help='Diagnose process creation on the old device kernel')
parser.add_argument('--trace-navigation', action='store_true', help='Write detailed Gecko navigation logs for this trial')
parser.add_argument('--own-compositor', action='store_true', help='Run inside the dedicated temporary Tundra compositor, without Phosh services')
parser.add_argument('--diagnostics', action='store_true', help='Open apps and scroll Gaia for the interactive qualification suite')
args = parser.parse_args()
if os.getuid() == 0:
raise RuntimeError('Run as the graphical session user, never root')
drop_capabilities()
trials = str(Path.home()/'vulpes-tundra-tests').encode()+b'/'
for proc in Path('/proc').glob('[0-9]*'):
try:
command = (proc/'cmdline').read_bytes().split(b'\0')
except OSError:
continue
if command and command[0].startswith(trials) and b'/product/engines/' in command[0]:
raise RuntimeError(f'A previous trial engine is still present: PID {proc.name}')
logs = ROOT/'logs'
logs.mkdir(exist_ok=True)
guard = (logs/'session.lock').open('w')
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
with socket.socket() as check:
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
check.bind(('127.0.0.1', 8765))
env = {k:v for k,v in os.environ.items() if not k.startswith('MOZ_DISABLE_')}
for key in ('MOZ_HEADLESS', 'MOZ_MARIONETTE', 'B2G_HOMESCREEN'):
env.pop(key, None)
runtime_dir = Path(f'/run/user/{os.getuid()}')
display = 'tundra-0' if args.own_compositor else 'wayland-0'
if not (runtime_dir/display).is_socket():
raise RuntimeError('Existing Wayland session not found')
env.update(XDG_RUNTIME_DIR=str(runtime_dir), WAYLAND_DISPLAY=display,
DBUS_SESSION_BUS_ADDRESS=env.get('DBUS_SESSION_BUS_ADDRESS',f'unix:path={runtime_dir}/bus'),
MOZ_ENABLE_WAYLAND='1', EGL_PLATFORM='wayland', GDK_GL='gles',
VULPES_FULLSCREEN='1', VULPES_NATIVE_TOUCH='1',
VULPES_WORKSPACE_ROOT=str(ROOT))
if args.hybris_egl:
import hashlib
graphics = ROOT/'trial/graphics'
manifest = json.loads((graphics/'manifest.json').read_text())
vendor = Path(manifest['vendorLibrary'])
adapter = graphics/'libEGL_vulpes_hybris.so'
for path, expected in [(vendor,manifest['vendorSha256']), (adapter,manifest['adapterSha256'])]:
if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise RuntimeError('EGL adapter/library changed; requalify before use: '+str(path))
descriptor = graphics/'vulpes-egl.json'
descriptor.write_text(json.dumps({'file_format_version':'1.0.0','ICD':{'library_path':str(adapter)}}))
env['__EGL_VENDOR_LIBRARY_FILENAMES'] = str(descriptor)
env['TUNDRA_DIAGNOSTICS'] = '1' if args.diagnostics else '0'
env['TUNDRA_BOOT_ID'] = Path('/proc/sys/kernel/random/boot_id').read_text().strip()
env['TUNDRA_HEADLESS'] = '1' if args.headless else '0'
env['TUNDRA_OWN_COMPOSITOR'] = '1' if args.own_compositor else '0'
if args.trace_navigation:
env.update(MOZ_LOG='sync,timestamp,nsDocShell:5,DocumentChannel:5,ProcessIsolation:5,nsHttp:5',
MOZ_LOG_FILE=str(logs/'navigation.log'))
if args.headless:
env.update(MOZ_HEADLESS='1', VULPES_FULLSCREEN='0')
inhibitor = None
if not args.headless and not args.own_compositor:
from gi.repository import Gio, GLib
bus = Gio.DBusConnection.new_for_address_sync(env['DBUS_SESSION_BUS_ADDRESS'],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
None, None)
locked = bus.call_sync('org.gnome.ScreenSaver', '/org/gnome/ScreenSaver',
'org.gnome.ScreenSaver', 'GetActive', None, GLib.VariantType.new('(b)'),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
if locked:
raise RuntimeError('Unlock the phone before starting the visible trial')
cookie = bus.call_sync('org.gnome.SessionManager', '/org/gnome/SessionManager',
'org.gnome.SessionManager', 'Inhibit',
GLib.Variant('(susu)', ('org.vulpes.TundraTrial', 0, 'Essai graphique Vulpes', 8)),
GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
# The session manager releases this temporary idle inhibitor when our
# bus connection closes, including after a crash. Manual lock still works.
inhibitor = bus
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())
runtime = host.prepare(lock, test=True)
import shutil
shutil.copy2(ROOT/'trial/probe.sys.mjs', runtime/'vulpes/tests/DroidianProbe.sys.mjs')
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/DroidianProbe.sys.mjs').run(win)
.catch(e => dump('TUNDRA_PROBE_ERROR '+e+'\\n'));
}, {once:true});
}}, 'domwindowopened');
''')
env['VULPES_HOST_ROOT'] = str(runtime/'vulpes/host')
profile = ROOT/'profiles/droidian-trial'
profile.mkdir(parents=True, exist_ok=True)
prefs = {'browser.shell.checkDefaultBrowser':False,
'font.default.x-western':'sans-serif',
'dom.w3c_touch_events.legacy_apis.enabled':True,
'font.name.sans-serif.x-western':'Fira Sans',
'browser.dom.window.dump.enabled':True,
'browser.startup.homepage_override.mstone':'ignore',
'browser.aboutwelcome.enabled':False,
'datareporting.policy.dataSubmissionEnabled':False,
'toolkit.telemetry.enabled':False, 'app.update.auto':False,
'marionette.enabled':env.get('TUNDRA_LOCAL_DEBUGGER')=='1',
'marionette.port':2858,'marionette.host':'127.0.0.1',
# Write both states: prefs.js retains values between trials.
'gfx.webrender.software':args.software,
'layers.acceleration.disabled':args.software,
'gfx.webrender.allow-partial-present-buffer-age':False,
'dom.ipc.forkserver.enable':not args.no_forkserver}
if args.software:
prefs.update({'gfx.webrender.software':True, 'layers.acceleration.disabled':True,
'media.ffmpeg.vaapi.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')
(logs/'session.pid').write_text(str(os.getpid())+'\n')
def stop(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop)
processes = []
try:
with (logs/'server.log').open('w') as output:
server = subprocess.Popen([sys.executable, '-u', str(ROOT/'tools/serve-gaia.py')],
stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(server)
for _ in range(100):
if server.poll() is not None:
raise RuntimeError('Gaia server exited: see logs/server.log')
try:
with socket.create_connection(('127.0.0.1',8765), timeout=.2):
break
except OSError:
time.sleep(.1)
else:
raise RuntimeError('Gaia server startup timeout')
with (logs/'gecko.log').open('w') as output:
debug_args=['--remote-allow-system-access','-marionette','-new-window','about:blank'] if env.get('TUNDRA_LOCAL_DEBUGGER')=='1' else []
firefox = subprocess.Popen([str(runtime/'firefox'), *debug_args, '-purgecaches', '-no-remote',
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml'],
env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(firefox)
result = firefox.wait()
if result:
raise RuntimeError(f'Gecko exited with {result}: see logs/gecko.log')
finally:
remaining = []
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
remaining.append(process.pid)
(logs/'stop.json').write_text(json.dumps({'unreapedProcesses':remaining})+'\n')
(logs/'session.pid').unlink(missing_ok=True)
if inhibitor:
inhibitor.close_sync(None)
if remaining:
raise RuntimeError(f'Kernel has not released trial processes: {remaining}')
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
pass