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
80
tundra/droidian/backup-boot.py
Normal file
80
tundra/droidian/backup-boot.py
Normal 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()
|
||||
102
tundra/droidian/check-runtime.py
Normal file
102
tundra/droidian/check-runtime.py
Normal 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()
|
||||
151
tundra/droidian/compositor-trial.py
Normal file
151
tundra/droidian/compositor-trial.py
Normal 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()
|
||||
155
tundra/droidian/export-runtime.py
Normal file
155
tundra/droidian/export-runtime.py
Normal 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()
|
||||
39
tundra/droidian/graphics/egl-config.c
Normal file
39
tundra/droidian/graphics/egl-config.c
Normal 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;
|
||||
}
|
||||
11
tundra/droidian/graphics/libhybris-b22d041.patch
Normal file
11
tundra/droidian/graphics/libhybris-b22d041.patch
Normal 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;
|
||||
6
tundra/droidian/graphics/vendor.json
Normal file
6
tundra/droidian/graphics/vendor.json
Normal 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"
|
||||
}
|
||||
18
tundra/droidian/initialize-runtime.sh
Normal file
18
tundra/droidian/initialize-runtime.sh
Normal 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
|
||||
29
tundra/droidian/inventory-hardware.py
Normal file
29
tundra/droidian/inventory-hardware.py
Normal 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()
|
||||
58
tundra/droidian/platform-inventory.py
Normal file
58
tundra/droidian/platform-inventory.py
Normal 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()
|
||||
43
tundra/droidian/prepare-session-root.py
Normal file
43
tundra/droidian/prepare-session-root.py
Normal 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()
|
||||
614
tundra/droidian/probe.sys.mjs
Normal file
614
tundra/droidian/probe.sys.mjs
Normal 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
208
tundra/droidian/session.py
Normal 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
|
||||
Loading…
Reference in a new issue