Release Vulpes OS 2.7-preview.22

This commit is contained in:
OverSu 2026-10-22 20:42:26 +02:00
commit a138f97877
294 changed files with 515786 additions and 187 deletions

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read boot/recovery via the KaiOS debugger. Never writes phone partitions."""
import argparse
import base64
from datetime import date
import hashlib
import json
import os
from pathlib import Path
import subprocess
from kaios_debug import KaiOSDebugger
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path,
default=ROOT / 'out/beatles/backups' / date.today().strftime('%Y%m%d'))
folder = parser.parse_args().output
folder.mkdir(parents=True, exist_ok=True)
os.chmod(folder, 0o700)
lines = subprocess.check_output(['adb', 'devices', '-l'], text=True).splitlines()
serials = [line.split()[0] for line in lines
if len(line.split()) > 2 and line.split()[1] == 'device'
and 'model:Nokia_2720_Flip' in line.split()]
assert len(serials) == 1
adb = ['adb', '-s', serials[0]]
assert subprocess.check_output(adb + ['shell', 'getprop ro.product.codename'], text=True).strip() == 'beatles'
firmware = subprocess.check_output(adb + ['shell', 'getprop ro.build.version.incremental'], text=True).strip()
if firmware != '40.00.17.01':
raise RuntimeError('Partition map only qualified for firmware 40.00.17.01')
parts = subprocess.check_output(adb + ['shell', 'cat /proc/partitions'], text=True)
sizes = {r.split()[3]: int(r.split()[2]) * 1024 for r in parts.splitlines() if len(r.split()) == 4 and r.split()[0].isdigit()}
selected = {'boot': 'mmcblk0p27', 'recovery': 'mmcblk0p28', 'aboot': 'mmcblk0p9', 'abootbak': 'mmcblk0p10'}
report = {'device': 'beatles', 'firmware': firmware, 'method': 'read-only XPCOM file stream through enabled debugger',
'stockFirmwareClaimed': False, 'restorationTested': False, 'partitions': {}}
with KaiOSDebugger(serials[0]) as client:
console = client.request('root', 'getProcess', id=0)['form']['consoleActor']
for name, block in selected.items():
output = folder / (name + '.img')
if output.exists():
raise RuntimeError('Backup already exists: ' + str(output))
target = '/dev/block/bootdevice/by-name/' + name
resolved = subprocess.check_output(adb + ['shell', 'readlink ' + target], text=True).strip()
assert resolved == '/dev/block/' + block
partial = output.with_suffix('.img.partial')
assert not partial.exists()
digest = hashlib.sha256()
count = 0
setup = '''(function(){var f=Components.classes["@mozilla.org/file/local;1"].createInstance(Components.interfaces.nsIFile);f.initWithPath(%s);var s=Components.classes["@mozilla.org/network/file-input-stream;1"].createInstance(Components.interfaces.nsIFileInputStream);s.init(f,1,0,0);var b=Components.classes["@mozilla.org/binaryinputstream;1"].createInstance(Components.interfaces.nsIBinaryInputStream);b.setInputStream(s);window.__vulpesBootBackup=b;return "opened-read-only";})()''' % json.dumps(target)
assert client.evaluate(console, setup) == 'opened-read-only'
try:
with partial.open('xb') as stream:
os.chmod(partial, 0o600)
while count < sizes[block]:
length = min(131072, sizes[block] - count)
encoded = client.evaluate(console, 'btoa(window.__vulpesBootBackup.readBytes(' + str(length) + '))')
data = base64.b64decode(encoded, validate=True)
assert len(data) == length
stream.write(data)
digest.update(data)
count += length
finally:
client.evaluate(console, '(function(){window.__vulpesBootBackup.close();delete window.__vulpesBootBackup;return true;})()')
assert count == sizes[block]
partial.rename(output)
report['partitions'][name] = {'bytes': count, 'sha256': digest.hexdigest(), 'device': target}
(folder / 'backup.json').write_text(json.dumps(report, indent=2) + '\n')
print(name, count, digest.hexdigest(), flush=True)
print('Read-only backups complete. Restoration is not yet qualified.')

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: MPL-2.0
"""Strict Android v0/newc handling for the inventoried Nokia boot image."""
import gzip
import hashlib
from pathlib import PurePosixPath
import stat
import struct
def align(value, boundary):
return (value + boundary - 1) // boundary * boundary
def unpack_boot(data):
if len(data) < 1632 or data[:8] != b'ANDROID!':
raise ValueError('Not an Android boot image')
fields = struct.unpack_from('<10I', data, 8)
kernel, _, ramdisk, _, second, _, _, page, dt, _ = fields
if page not in (2048, 4096) or not kernel or not ramdisk or second or dt:
raise ValueError('Expected Nokia v0 image without second stage or separate DT')
offset = page + align(kernel, page)
end = offset + align(ramdisk, page)
if end > len(data):
raise ValueError('Truncated boot image')
return {'header': data[:page], 'kernel': data[page:page + kernel],
'ramdisk': data[offset:offset + ramdisk], 'trailer': data[end:], 'page': page}
def pack_boot(original, ramdisk):
parts = unpack_boot(original)
header = bytearray(parts['header'])
struct.pack_into('<I', header, 16, len(ramdisk))
kernel = parts['kernel']
digest = hashlib.sha1()
for payload in (kernel, ramdisk, b''):
digest.update(payload); digest.update(struct.pack('<I', len(payload)))
header[576:608] = digest.digest() + b'\0' * 12
page = parts['page']
# The original signature cannot authenticate changed bytes. Never copy it.
return (bytes(header) + kernel + b'\0' * (align(len(kernel), page) - len(kernel)) +
ramdisk + b'\0' * (align(len(ramdisk), page) - len(ramdisk)))
def read_cpio(data):
raw = gzip.decompress(data)
result = {}; offset = 0
while offset + 110 <= len(raw):
header = raw[offset:offset + 110]
if header[:6] != b'070701':raise ValueError('Expected newc archive')
fields = [int(header[6+i*8:14+i*8], 16) for i in range(13)]
size, namesize = fields[6], fields[11]
if namesize < 1:raise ValueError('Empty cpio name')
name_start = offset + 110; name_end = name_start + namesize
start = align(name_end, 4); end = start + size
if end > len(raw) or raw[name_end-1:name_end] != b'\0':raise ValueError('Truncated cpio entry')
name = raw[name_start:name_end-1].decode()
if name == 'TRAILER!!!':return result
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or name in result:raise ValueError('Unsafe cpio entry')
result[name] = {'fields':fields, 'data':raw[start:end]}
offset = align(end, 4)
raise ValueError('Missing cpio trailer')
def write_cpio(entries):
result = bytearray()
records = list(entries.items()) + [('TRAILER!!!', {'fields':[0]*13, 'data':b''})]
for name, entry in records:
fields = list(entry['fields']); payload = entry['data']; encoded = name.encode() + b'\0'
fields[6] = len(payload); fields[11] = len(encoded)
result.extend(b'070701' + b''.join(('%08x' % x).encode() for x in fields))
result.extend(encoded); result.extend(b'\0' * (align(len(result), 4)-len(result)))
result.extend(payload); result.extend(b'\0' * (align(len(result), 4)-len(result)))
result.extend(b'\0' * (align(len(result), 512)-len(result)))
return gzip.compress(result, mtime=0)
def add_file(entries, name, data, mode=0o644):
if name in entries:raise ValueError('Ramdisk file already exists: '+name)
inode = max(item['fields'][0] for item in entries.values()) + 1
entries[name] = {'fields':[inode, stat.S_IFREG | mode, 0, 0, 1, 0, len(data), 0, 0, 0, 0, 0, 0], 'data':data}

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a Linux ARMv7 EABI diagnostic; never connects to a device."""
import hashlib
import json
from pathlib import Path
import struct
import subprocess
ROOT = Path(__file__).resolve().parents[1]
source = ROOT / 'probes/armv7-smoke.c'
folder = ROOT / 'out/beatles/armv7-smoke'
folder.mkdir(parents=True, exist_ok=True)
binary = folder / 'vulpes-armv7-probe'
command = ['clang', '--target=armv7-linux-gnueabihf', '-march=armv7-a',
'-marm', '-mfloat-abi=soft', '-fuse-ld=lld', '-Os',
'-ffreestanding', '-fno-builtin', '-fno-stack-protector',
'-fno-pie', '-fno-unwind-tables', '-fno-asynchronous-unwind-tables',
'-Wall', '-Wextra', '-Werror', '-nostdlib', '-static',
'-Wl,--build-id=none', '-Wl,-e,_start', str(source), '-o', str(binary)]
subprocess.run(command, check=True)
data = binary.read_bytes()
assert data[:6] == b'\x7fELF\x01\x01'
assert struct.unpack_from('<H', data, 18)[0] == 40 # EM_ARM
headers = subprocess.check_output(['readelf', '-l', str(binary)], text=True)
assert 'INTERP' not in headers
metadata = {
'kind': 'native-userspace-diagnostic', 'architecture': 'armv7',
'containsGecko': False, 'bootImage': False, 'flashReady': False,
'sourceSha256': hashlib.sha256(source.read_bytes()).hexdigest(),
'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data),
'compiler': subprocess.check_output(['clang', '--version'], text=True),
'command': command,
}
(folder / 'build.json').write_text(json.dumps(metadata, indent=2) + '\n')
print(binary)
print(metadata['sha256'])

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build an unsigned private Nokia boot trial. Never flashes or unlocks a phone."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
from beatles_boot import unpack_boot, pack_boot, read_cpio, write_cpio, add_file
ROOT=Path(__file__).resolve().parents[2]
ENGINE=ROOT/'tundra/out/beatles/gecko-156.0.1'
def digest(data):return hashlib.sha256(data).hexdigest()
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--backup',type=Path,default=ROOT/'tundra/out/beatles/backups/20261017')
parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args();out=args.output.resolve()
if out.exists():raise ValueError('Choose a new output directory')
backup=json.loads((args.backup/'backup.json').read_text())
verified=json.loads((args.backup/'verification.json').read_text())
if backup['device']!='beatles' or backup['firmware']!='40.00.17.01':raise ValueError('Unsupported firmware')
for name in ('boot','recovery','aboot','abootbak'):
data=(args.backup/(name+'.img')).read_bytes(); record=backup['partitions'][name]
if len(data)!=record['bytes'] or digest(data)!=record['sha256'] or verified['partitions'][name]['sha256']!=record['sha256']:
raise ValueError('Backup verification failed: '+name)
original=(args.backup/'boot.img').read_bytes();parts=unpack_boot(original)
entries=read_cpio(parts['ramdisk'])
rc=entries['init.b2g.rc']['data']
expected=b'service b2g /system/bin/b2g.sh\n class main\n'
if rc.count(expected)!=1:raise ValueError('Unexpected KaiOS service definition')
entries['init.b2g.rc']['data']=rc.replace(expected,expected+b' disabled\n')
entries['init.rc']['data']+=b'\nimport /init.vulpes.rc\n'
out.mkdir(parents=True,mode=0o700)
sysroot=ENGINE/'sysroot/rootfs'
compiler=[str(ENGINE/'mozbuild/clang/bin/clang'),'--target=arm-linux-gnueabihf',
'--sysroot='+str(sysroot),'--gcc-install-dir='+str(sysroot/'usr/lib/gcc/arm-linux-gnueabihf/12'),
'-fuse-ld=lld','-march=armv7-a','-mfpu=neon','-mfloat-abi=hard','-O2','-Wall','-Wextra','-Werror']
subprocess.run(compiler+['-static',str(ROOT/'tundra/probes/beatles-control-client.c'),'-o',str(out/'control-client')],check=True)
supervisor=out/'vulpes-display'
subprocess.run(compiler+['-static',str(ROOT/'tundra/probes/beatles-display-session.c'),'-o',str(supervisor)],check=True)
add_file(entries,'sbin/vulpes-display',supervisor.read_bytes(),0o755)
for name in ('init.vulpes.rc','vulpes-boot.sh'):
add_file(entries,name,(ROOT/'tundra/devices/beatles/boot'/name).read_bytes(),0o755 if name.endswith('.sh') else 0o644)
ramdisk=write_cpio(entries);image=pack_boot(original,ramdisk)
if len(image)>backup['partitions']['boot']['bytes']:raise ValueError('Boot partition too small')
check=unpack_boot(image)
if check['kernel']!=parts['kernel']:raise ValueError('Kernel changed')
reread=read_cpio(check['ramdisk'])
for name,value in entries.items():
if reread[name]['data']!=value['data']:raise ValueError('Ramdisk data mismatch: '+name)
# Only size/name size may change when serializing; owners and modes must survive.
if any(reread[name]['fields'][i]!=value['fields'][i] for i in (0,1,2,3,4,5,7,8,9,10,12)):
raise ValueError('Ramdisk metadata mismatch: '+name)
name='vulpes-beatles-boot-trial-UNSIGNED.img';(out/name).write_bytes(image)
record={'device':'beatles','firmware':'40.00.17.01','image':name,'sha256':digest(image),
'bytes':len(image),'kernelSha256':digest(parts['kernel']),'originalBootSha256':digest(original),
'controlClientSha256':digest((out/'control-client').read_bytes()),
'gecko':'156.0.1','runtimePath':'/data/local/vulpes-beatles','deadlineSeconds':1800,
'unsigned':True,'bootTested':False,'flashReady':False,'standaloneSystem':False,
'dependencies':['Existing vendor /system and modem partitions','Prepared persistent runtime, not included in boot partition'],
'recovery':'KaiOS fallback if runtime is missing or fails, or after 30 minutes; boot partition restoration not qualified',
'sourceFiles':{str(p.relative_to(ROOT)):digest(p.read_bytes()) for p in [Path(__file__),ROOT/'tundra/tools/beatles_boot.py',ROOT/'tundra/probes/beatles-display-session.c',ROOT/'tundra/probes/beatles-control-client.c',ROOT/'tundra/probes/beatles-lid.h',ROOT/'tundra/probes/beatles-hall.h',ROOT/'tundra/devices/beatles/boot/init.vulpes.rc',ROOT/'tundra/devices/beatles/boot/vulpes-boot.sh']}}
(out/'build.json').write_text(json.dumps(record,indent=2)+'\n')
(out/'SHA256SUMS').write_text(record['sha256']+' '+name+'\n')
print('Built private unsigned boot trial:',out/name)
print('Not boot-tested or flash-qualified; existing vendor system and staged runtime required.')
if __name__=='__main__':main()

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
set -euo pipefail
root=$(cd -- "$(dirname -- "$0")/.." && pwd)
build="$root/out/beatles/gecko-156.0.1"
sysroot="$build/sysroot/rootfs"
export MOZBUILD_STATE_PATH="$build/mozbuild"
python=${VULPES_BUILD_PYTHON:-/home/archiviste/.local/bin/python3.12}
jobs=${VULPES_BUILD_JOBS:-4}
[[ "$jobs" =~ ^[1-9][0-9]*$ ]] || { echo "Invalid VULPES_BUILD_JOBS" >&2; exit 2; }
rust=$(rustup which --toolchain 1.97.1 rustc)
export PATH="$(dirname "$rust"):$MOZBUILD_STATE_PATH/clang/bin:$MOZBUILD_STATE_PATH/node/bin:/usr/bin:/bin:$PATH"
export MOZCONFIG="$build/mozconfig"
export MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE=none
test "$(cat "$build/src/browser/config/version.txt")" = 156.0.1
test -f "$sysroot/usr/include/gtk-3.0/gtk/gtk.h"
test -x "$MOZBUILD_STATE_PATH/clang/bin/clang"
config_tmp=$(mktemp "$build/mozconfig.XXXXXX")
trap 'rm -f -- "$config_tmp"' EXIT
cat > "$config_tmp" <<EOF
mk_add_options MOZ_OBJDIR=$build/obj-armv7
mk_add_options MOZ_MAKE_FLAGS=-j$jobs
ac_add_options --enable-application=browser
ac_add_options --target=armv7-unknown-linux-gnueabihf
ac_add_options --disable-bootstrap
ac_add_options --disable-debug
ac_add_options --disable-debug-symbols
ac_add_options --disable-tests
ac_add_options --disable-crashreporter
ac_add_options --disable-updater
ac_add_options --enable-optimize=-O2
ac_add_options --with-libclang-path=$MOZBUILD_STATE_PATH/clang/lib
export CC="$MOZBUILD_STATE_PATH/clang/bin/clang --target=armv7-linux-gnueabihf --sysroot=$sysroot --gcc-install-dir=$sysroot/usr/lib/gcc/arm-linux-gnueabihf/12"
export CXX="$MOZBUILD_STATE_PATH/clang/bin/clang++ --target=armv7-linux-gnueabihf --sysroot=$sysroot --gcc-install-dir=$sysroot/usr/lib/gcc/arm-linux-gnueabihf/12"
export HOST_CC=$MOZBUILD_STATE_PATH/clang/bin/clang
export HOST_CXX=$MOZBUILD_STATE_PATH/clang/bin/clang++
export WASM_CC="$MOZBUILD_STATE_PATH/clang/bin/clang --sysroot=$MOZBUILD_STATE_PATH/sysroot-wasm32-wasi"
export WASM_CXX="$MOZBUILD_STATE_PATH/clang/bin/clang++ --sysroot=$MOZBUILD_STATE_PATH/sysroot-wasm32-wasi"
export CFLAGS="-march=armv7-a -mfpu=neon -mfloat-abi=hard"
export CXXFLAGS="-march=armv7-a -mfpu=neon -mfloat-abi=hard"
export PKG_CONFIG=/usr/bin/pkg-config
export PKG_CONFIG_SYSROOT_DIR=$sysroot
export PKG_CONFIG_LIBDIR=$sysroot/usr/lib/arm-linux-gnueabihf/pkgconfig:$sysroot/usr/share/pkgconfig
export RUSTC=$rust
export CARGO=$(rustup which --toolchain 1.97.1 cargo)
export CBINDGEN=$MOZBUILD_STATE_PATH/cbindgen/cbindgen
export NODEJS=$MOZBUILD_STATE_PATH/node/bin/node
EOF
if ! cmp -s "$config_tmp" "$MOZCONFIG"; then
cp "$config_tmp" "$MOZCONFIG"
fi
rm -f -- "$config_tmp"
trap - EXIT
cd "$build/src"
exec "$python" mach "${@:-build}"

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build the read-only sensor HAL probe using private Nokia ABI libraries.
No flashing or device execution. Run the resulting library in a separate
Android /system/bin/true process with LD_PRELOAD, never in Gecko itself.
The constructor enumerates sensors without activating them and exits.
"""
import argparse,hashlib,json,subprocess
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
def sha(path):return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--libraries',type=Path,required=True,help='Private directory containing libc.so and libdl.so pulled from this Nokia')
parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args();source=ROOT/'tundra/probes/beatles-hal-inventory.c'
libraries=[args.libraries/name for name in ('libc.so','libdl.so')]
for path in libraries:
data=path.read_bytes()
if data[:6]!=b'\x7fELF\x01\x01' or data[18:20]!=b'\x28\x00':raise ValueError('Requires ARM32 Nokia libraries')
args.output.parent.mkdir(exist_ok=True,parents=True)
command=['clang','--target=armv7-linux-androideabi','-march=armv7-a','-mfloat-abi=softfp',
'-fuse-ld=lld','-shared','-fPIC','-nostdlib','-O2','-Wall','-Wextra','-Werror',
'-Wl,--no-undefined','-Wl,-soname,libvulpes-hal-inventory.so',str(source),
*map(str,libraries),'-o',str(args.output)]
subprocess.run(command,check=True)
args.output.with_suffix('.build.json').write_text(json.dumps({'device':'beatles','abi':'Android 6 ARM32',
'operation':'sensor enumeration only; no activation','sourceSha256':sha(source),
'sha256':sha(args.output),'privateLibraries':{p.name:sha(p) for p in libraries},
'command':command},indent=2)+'\n')
print(args.output)
if __name__=='__main__':main()

View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Build small, offline T9 lexicons from Gaia's bundled language wordlists."""
import argparse
import heapq
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WORDLISTS = ROOT / 'gaia/apps/keyboard/js/imes/latin/dictionaries'
OUTPUT = ROOT / 'tundra/devices/beatles/t9'
def build(source: Path, destination: Path, limit: int) -> int:
heap = []
sequence = 0
for _, node in ET.iterparse(source, events=('end',)):
if node.tag != 'w':
continue
word = (node.text or '').strip().lower()
if not word or not word.isalpha():
node.clear()
continue
try:
frequency = int(node.attrib.get('f', '0'))
except ValueError:
frequency = 0
sequence += 1
item = (frequency, sequence, word)
if len(heap) < limit:
heapq.heappush(heap, item)
elif item > heap[0]:
heapq.heapreplace(heap, item)
node.clear()
# Stable, high-frequency-first output makes lookups deterministic.
rows = sorted({(word, frequency) for frequency, _, word in heap},
key=lambda item: (-item[1], item[0]))
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(''.join(f'{word}\t{frequency}\n' for word, frequency in rows), encoding='utf-8')
return len(rows)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--limit', type=int, default=12000,
help='maximum frequent words per Gaia language (default: 12000)')
args = parser.parse_args()
if args.limit < 100 or args.limit > 100000:
parser.error('--limit must be between 100 and 100000')
sources = sorted(WORDLISTS.glob('*_wordlist.xml'))
if not sources:
raise SystemExit('Gaia keyboard wordlists are missing')
languages = []
for source in sources:
locale = source.name.removesuffix('_wordlist.xml')
count = build(source, OUTPUT / f'{locale.lower()}.tsv', args.limit)
languages.append((locale, count))
print(f'Built {len(languages)} offline dictionaries ({sum(n for _, n in languages):,} words).')
for locale, count in languages:
print(f'{locale}: {count:,}')
if __name__ == '__main__':
main()

View file

@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
from ramdisk_reference import read_newc, select_helpers
from storage_layout import mount_table
from shared_ui import compress_archive
from tundra import ROOT, cpio, checked_source, digest, save, linker
@ -34,6 +35,7 @@ def main():
help='Private per-phone workspace; never use the development inventory')
parser.add_argument('--native',action='store_true',help='Switch to the staged native root with a ten-minute watchdog')
parser.add_argument('--system-image',type=Path,help='system.json for a self-contained image staged at /tundra/images/<sha256>.ext4')
parser.add_argument('--differential-ui', action='store_true', help='Bundle only UI files differing from the verified immutable system image')
parser.add_argument('--hold-hal',action='store_true',help='Boot basic.target first so HAL startup can be traced over USB')
parser.add_argument('--runtime-config', action='store_true', help='Apply the current runtime recipe to the RAM overlay')
parser.add_argument('--network', action='store_true', help='Enable experimental WLAN and isolated Qualcomm NV copies; requires --runtime-config')
@ -45,6 +47,7 @@ def main():
args=parser.parse_args()
if args.hold_hal and not args.native:parser.error('--hold-hal requires --native')
if args.system_image and not args.native:parser.error('--system-image requires --native')
if args.differential_ui and not args.system_image:parser.error('--differential-ui requires --system-image')
if args.runtime_config and not args.native:parser.error('--runtime-config requires --native')
if args.hardware and not args.network:parser.error('--hardware requires --network')
if args.network and not args.runtime_config:parser.error('--network requires --runtime-config')
@ -169,7 +172,7 @@ def main():
config_dir = configroot/directory
if config_dir.exists():
archive.add(config_dir, arcname=directory, filter=owned)
files['native-config.tar']=(0o600,buffer.getvalue())
files['native-config.tar']=(0o600,compress_archive(buffer.getvalue()))
sources += list(sorted((ROOT/'runtime').glob('*.py')))
files['sbin/tundra-watchdog']=(0o755,binary.read_bytes())
native_init=(ROOT/'boot/native-root.sh').read_bytes().replace(b'# BUILD_TIME_FLOOR', ('BUILD_EPOCH='+str(built_at)).encode())
@ -181,8 +184,17 @@ def main():
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
# Bundle UI changes with the boot trial rather than retransferring
# the entire immutable system image for each CSS/JS adjustment.
from shared_ui import build_payload
from shared_ui import build_payload, differential_payload
payload, shared_ui = build_payload()
if args.differential_ui:
with tempfile.TemporaryDirectory() as directory:
extracted = Path(directory)/'opt'
extracted.mkdir()
subprocess.run(['debugfs', '-R', 'rdump /opt/vulpes '+str(extracted), str(image_path)],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not (extracted/'vulpes/release.json').is_file():
raise ValueError('Cannot inspect the immutable Gaia filesystem')
payload, omitted_ui = differential_payload(payload, Path(directory))
files['shared-ui.tar'] = (0o600, payload)
sources.append(ROOT/'tools/shared_ui.py')
if state:
@ -233,6 +245,7 @@ def main():
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (600 if args.native else 480),'persistentState':state,'storageWrites':bool(state),'nativeRootRequested':args.native,'halHeldForDiagnostics':args.hold_hal,
'layout':layout,'layoutSha256':digest(args.layout),'systemImage':system,'sharedUiHashes':shared_ui,'sourceSnapshot':True,'sources':{str(p.relative_to(ROOT)):digest(p) for p in sources},
'helpers':{n:__import__('hashlib').sha256(b).hexdigest() for n,b in helpers.items()},
'sharedUiFilesAlreadyInSystem':omitted_ui if args.differential_ui else 0,
'containsPrivateTrialHostKey':True,'installedKeysCopied':False})
print(image)

View file

@ -81,7 +81,7 @@ def main():
command = [sys.executable,str(ROOT/'tools/build-rescue-boot.py'), '--output',str(args.output),
'--device-workspace',str(args.device_workspace),'--layout',str(args.device_workspace/'layout.json'),
'--native','--runtime-config','--network','--hardware',
'--system-image',str(args.prepared/'system.json'),'--state',str(args.prepared/'data/state.json')]
'--system-image',str(args.prepared/'system.json'),'--differential-ui','--state',str(args.prepared/'data/state.json')]
if args.no_deadline: command.append('--no-deadline')
if args.local_debugger: command.append('--local-debugger')
subprocess.run(command,check=True)
@ -126,6 +126,8 @@ ln -sf ../tundra-boot-success.service /newroot/etc/systemd/system/tundra.target.
image=out/'boot.img'; command=[sys.executable,str(packager/'mkbootimg.py')]
for key,value in fields.items():command.extend([key,value])
subprocess.run(command+['-o',str(image)],check=True)
if image.stat().st_size > 67108864:
raise ValueError('Final standalone boot exceeds the 64 MiB boot partition')
verify=Path(temp)/'verify'
subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
if digest(verify/'kernel')!=build['kernelSha256'] or digest(verify/'ramdisk')!=digest(ramdisk):

View file

@ -78,6 +78,17 @@ def module(name, path):
return result
def copy_source_overrides(product):
"""Include the editable Gaia files used by the desktop and APK."""
project = module('vulpes_project', PROJECT/'tools/project.py')
for app, names in project.SOURCE_OVERRIDES.items():
for name in names:
relative = Path('gaia/apps')/app/name
target = product/relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(PROJECT/relative, target)
def pack(root, image):
if not os.environ.get('FAKEROOTKEY'):
raise RuntimeError('Image ownership must be prepared under fakeroot')
@ -126,6 +137,7 @@ def main():
for name in ('assets', 'host', 'services', 'adapters', 'overrides', 'gaia/shared', 'gaia/apps/system'):
shutil.copytree(PROJECT/name, product/name, symlinks=True,
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
copy_source_overrides(product)
for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True)
for name in ('serve-gaia.py', 'project.py', 'run-host.py', 'installed_apps.py'):

View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Package the Nokia display diagnostic; --install sideloads only this app."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import time
import zipfile
from kaios_debug import KaiOSDebugger
ROOT = Path(__file__).resolve().parents[1]
APP_ID = 'vulpes-diagnostic-beatles'
MANIFEST = 'app://' + APP_ID + '/manifest.webapp'
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--install', action='store_true')
parser.add_argument('--replace', action='store_true', help='Update only the existing Vulpes Diagnostic app')
args = parser.parse_args()
folder = ROOT / 'out/beatles/display-diagnostic'
folder.mkdir(parents=True, exist_ok=True)
archive = folder / 'application.zip'
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as z:
for p in sorted((ROOT / 'devices/beatles/diagnostic-app').iterdir()):
if p.is_file():
z.writestr(zipfile.ZipInfo(p.name, (2026, 10, 17, 0, 0, 0)), p.read_bytes(),
compress_type=zipfile.ZIP_DEFLATED)
print('Diagnostic package:', archive)
if not args.install:
raise SystemExit(0)
lines = subprocess.check_output(['adb', 'devices', '-l'], text=True).splitlines()
phones = []
for line in lines:
fields = line.split()
if len(fields) > 2 and fields[1] == 'device' and 'model:Nokia_2720_Flip' in fields:
serial = fields[0]
codename = subprocess.check_output(['adb', '-s', serial, 'shell',
'getprop ro.product.codename'], text=True).strip()
if codename == 'beatles':
phones.append(serial)
if len(phones) != 1:
raise SystemExit('Connect exactly one Nokia 2720 Flip (beatles) with ADB enabled.')
report = {'device': 'beatles', 'manifestURL': MANIFEST,
'sha256': hashlib.sha256(archive.read_bytes()).hexdigest(),
'usesExistingKaiOSGecko': True, 'newGeckoPorted': False, 'flashPerformed': False}
try:
with KaiOSDebugger(phones[0]) as client:
actor = client.root['webappsActor']
existing = client.request(actor, 'getAll')['apps']
previous = next((a for a in existing if a.get('manifestURL') == MANIFEST), None)
if previous:
if not args.replace:
raise RuntimeError('Diagnostic app already exists; use --replace to update it.')
if previous.get('manifest', {}).get('name') != 'Vulpes Diagnostic':
raise RuntimeError('Unexpected application using the diagnostic identifier')
client.request(actor, 'close', manifestURL=MANIFEST)
upload = client.request(actor, 'uploadPackage', bulk=False)['actor']
data = archive.read_bytes()
for offset in range(0, len(data), 16384):
chunk = data[offset:offset + 16384]
response = client.request(upload, 'chunk', chunk=chunk.decode('latin1'))
assert response['written'] == len(chunk)
client.request(upload, 'done')
report['install'] = client.request(actor, 'install', appId=APP_ID, upload=upload)
(folder / 'install-report.json').write_text(json.dumps(report, indent=2) + '\n')
report['launch'] = client.request(actor, 'launch', manifestURL=MANIFEST)
time.sleep(1)
app = client.request(actor, 'getAppActor', manifestURL=MANIFEST)['actor']
report['page'] = json.loads(client.evaluate(app['consoleActor'],
'JSON.stringify({title:document.title,ready:document.readyState,'
'width:innerWidth,height:innerHeight,body:document.body.innerText,'
'userAgent:navigator.userAgent})'))
assert report['page']['title'] == 'Vulpes — Diagnostic Nokia'
print(json.dumps(report['page'], ensure_ascii=False, indent=2))
finally:
(folder / 'install-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: MPL-2.0
"""Small client for the Gecko 48 debugger exposed by KaiOS over ADB."""
import json
import socket
import subprocess
class KaiOSDebugger:
def __init__(self, serial):
self.adb = ['adb', '-s', serial]
self.port = None
self.socket = None
def __enter__(self):
self.port = subprocess.check_output(self.adb + [
'forward', 'tcp:0', 'localfilesystem:/data/local/debugger-socket'],
text=True, timeout=15).strip()
try:
self.socket = socket.create_connection(('127.0.0.1', int(self.port)), timeout=20)
self.greeting = self.receive()
self.root = self.request('root', 'listTabs')
return self
except BaseException:
self.__exit__(None, None, None)
raise
def __exit__(self, *unused):
if self.socket:
self.socket.close()
if self.port:
subprocess.run(self.adb + ['forward', '--remove', 'tcp:' + self.port],
check=True, timeout=15)
def receive(self):
prefix = bytearray()
while True:
byte = self.socket.recv(1)
if not byte:
raise EOFError('KaiOS debugger disconnected')
if byte == b':':
break
prefix.extend(byte)
if len(prefix) > 12:
raise ValueError('Invalid debugger packet header')
remaining = int(prefix)
if remaining > 16 * 1024 * 1024:
raise ValueError('Debugger packet exceeds diagnostic limit')
data = bytearray()
while remaining:
part = self.socket.recv(remaining)
if not part:
raise EOFError('Incomplete debugger packet')
data.extend(part)
remaining -= len(part)
return json.loads(data)
def request(self, actor, kind, **fields):
body = json.dumps(dict(to=actor, type=kind, **fields)).encode()
self.socket.sendall(str(len(body)).encode() + b':' + body)
while True:
result = self.receive()
if result.get('from') != actor or result.get('type') in (
'consoleAPICall', 'pageError', 'networkEvent', 'newGlobal'):
continue
if 'error' in result:
raise RuntimeError(json.dumps(result))
return result
def evaluate(self, console, expression):
result = self.request(console, 'evaluateJS', text=expression)
if result.get('exceptionMessage'):
raise RuntimeError(str(result['exceptionMessage']))
value = result.get('result')
if isinstance(value, dict) and value.get('type') == 'longString':
actor = value['actor']
try:
value = self.request(actor, 'substring', start=0,
end=value['length'])['substring']
finally:
self.request(actor, 'release')
return value

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Assemble private boot/runtime artifacts without including phone profiles."""
import argparse
import hashlib
import io
import json
import subprocess
from pathlib import Path
import tarfile
ROOT=Path(__file__).resolve().parents[2]
ENGINE=ROOT/'tundra/out/beatles/gecko-156.0.1'
def digest(path):
with path.open('rb') as stream:return hashlib.file_digest(stream,'sha256').hexdigest()
def add_beatles_t9(files):
dictionaries=sorted((ROOT/'tundra/devices/beatles/t9').glob('*.tsv'))
if len(dictionaries)!=42:
raise ValueError('Expected the 42 bundled Gaia T9 lexicons')
for path in dictionaries:
files['opt/vulpes/tundra/devices/beatles/t9/'+path.name]=path
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
args=parser.parse_args();out=args.build.resolve()
record=json.loads((out/'build.json').read_text())
if record['device']!='beatles' or digest(out/record['image'])!=record['sha256']:raise ValueError('Boot build mismatch')
if (out/'runtime.json').exists():raise ValueError('Runtime already packaged')
engine=json.loads((ENGINE/'engine-staging.json').read_text())
graphics=json.loads((ENGINE/'graphics-runtime.json').read_text())
gaia=json.loads((ENGINE/'gaia-shared.json').read_text())
sources={'engine-runtime.tar.gz':engine['archiveSha256'],
'graphics-runtime.tar.gz':graphics['sha256'],'gaia-shared.tar.gz':gaia['sha256']}
for name,expected in sources.items():
path=ENGINE/name
if digest(path)!=expected:raise ValueError('Input archive changed: '+name)
# Use copy-on-write when available; future rebuilds must not mutate this artifact.
subprocess.run(["cp","--reflink=auto",str(path),str(out/name)],check=True)
files={}
for directory in ('host','services','adapters'):
for p in sorted((ROOT/directory).rglob('*')):
if p.is_file() and '__pycache__' not in p.parts:
files['opt/firefox/vulpes/'+str(p.relative_to(ROOT))]=p
if directory=='host':
files['opt/vulpes/'+str(p.relative_to(ROOT))]=p
files['opt/firefox/vulpes/tests/BeatlesAudit.sys.mjs']=ROOT/'tundra/devices/beatles/gaia-audit.sys.mjs'
files['opt/firefox/vulpes/tests/ProbeChild.sys.mjs']=ROOT/'tests/ProbeChild.sys.mjs'
files['opt/vulpes/assets/webapps/settings.gaiamobile.org/js/modules/wifi_utils.js']=ROOT/'assets/webapps/settings.gaiamobile.org/js/modules/wifi_utils.js'
add_beatles_t9(files)
for name in ('gaia-session.py','cover-display.py','radio_control.py','radio_status.py','call_audio.py','stream_volume.py','hardware_control.py','wifi_control.py','sms_pdu.py'):
files['work/'+name]=ROOT/'tundra/devices/beatles'/name
if digest(out/'control-client')!=record['controlClientSha256']:raise ValueError('Control relay build mismatch')
files['work/control-client']=out/'control-client'
files['work/keypad']=ENGINE/'keypad'
files['etc/localtime']=Path('/usr/share/zoneinfo/UTC')
directories={'work':0o1777,'tmp':0o1777,'proc':0o755,'sys':0o755,'dev':0o755,'run':0o755}
archive=out/'trial-overlay.tar.gz'
with tarfile.open(archive,'w:gz',compresslevel=1) as tar:
for name,mode in directories.items():
item=tarfile.TarInfo(name);item.type=tarfile.DIRTYPE;item.mode=mode;tar.addfile(item)
for name,path in files.items():
content=path.read_bytes();item=tarfile.TarInfo(name);item.size=len(content)
item.mode=0o755 if name in ('work/keypad','work/control-client') else 0o644
tar.addfile(item,io.BytesIO(content))
content=b'exec /usr/bin/python3.11 /work/gaia-session.py\n'
item=tarfile.TarInfo('work/display-test.sh');item.mode=0o644;item.size=len(content);tar.addfile(item,io.BytesIO(content))
sources[archive.name]=digest(archive)
result={'device':'beatles','engine':'156.0.1','runtimePath':record['runtimePath'],
'archivesInExtractionOrder':['graphics-runtime.tar.gz','engine-runtime.tar.gz','gaia-shared.tar.gz','trial-overlay.tar.gz'],
'archives':{name:{'sha256':sha,'bytes':(out/name).stat().st_size} for name,sha in sources.items()},
'sourceFiles':{name:digest(path) for name,path in files.items()},
'containsPhoneUserData':False,'privateDiagnosticBuild':True,'flashReady':False,
'defaultTimezone':'UTC; staging may use the phone timezone',
'requiresExistingVendorSystem':True}
(out/'runtime.json').write_text(json.dumps(result,indent=2)+'\n')
with (out/'SHA256SUMS').open('a') as f:
for name,sha in sources.items():f.write(sha+' '+name+'\n')
print('Runtime archives assembled; no contacts, messages or device profile copied.')
if __name__=='__main__':main()

View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Export shared Gaia and its host for the private Nokia experiment."""
import hashlib
import json
from pathlib import Path
import sys
import tarfile
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'tools'))
from project import SOURCE_OVERRIDES, validate_assets
def shared_files():
validate_assets()
files = set()
for directory in ('assets','host','services','adapters','overrides','gaia/shared'):
files.update(p for p in (ROOT/directory).rglob('*') if p.is_file())
for app, paths in SOURCE_OVERRIDES.items():
files.update(ROOT/'gaia/apps'/app/path for path in paths)
files.update(ROOT/path for path in ('release.json','engine-lock.json','tools/project.py',
'tools/serve-gaia.py','tools/installed_apps.py'))
return sorted(p for p in files if '__pycache__' not in p.parts and p.suffix!='.pyc')
def main():
out = ROOT/'tundra/out/beatles/gecko-156.0.1'
files = shared_files()
records = {}
with tarfile.open(out/'gaia-shared.tar.gz','w:gz',compresslevel=1,dereference=True) as archive:
for path in sorted(files):
if '__pycache__' in path.parts or path.suffix=='.pyc':continue
relative=str(path.relative_to(ROOT))
archive.add(path,arcname='opt/vulpes/'+relative)
records[relative]=hashlib.sha256(path.read_bytes()).hexdigest()
with (out/'gaia-shared.tar.gz').open('rb') as stream:
digest=hashlib.file_digest(stream,'sha256').hexdigest()
record={'device':'beatles','gaiaSource':'shared workspace',
'sha256':digest,'bytes':(out/'gaia-shared.tar.gz').stat().st_size,'files':records}
(out/'gaia-shared.json').write_text(json.dumps(record,indent=2)+'\n')
print('Shared Gaia files:',len(records),'Archive bytes:',record['bytes'])
if __name__=='__main__':main()

View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Package the isolated ARMhf runtime; no phone changes."""
import hashlib
import json
import posixpath
from pathlib import Path
import tarfile
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / 'out/beatles/gecko-156.0.1'
def main():
source = BUILD / 'sysroot/rootfs'
paths = ('lib/arm-linux-gnueabihf', 'lib/ld-linux-armhf.so.3',
'usr/lib/arm-linux-gnueabihf', 'usr/lib/xorg',
'usr/lib/python3.11', 'usr/bin/python3.11', 'usr/bin/xkbcomp',
'bin/dash', 'etc/fonts', 'usr/share/fonts',
'usr/share/fontconfig', 'usr/share/X11')
def keep(member):
if member.name.endswith(('.a', '.o', '.pyc')) or '/__pycache__/' in member.name:
return None
if member.issym() and member.linkname.startswith('/'):
member.linkname = posixpath.relpath(member.linkname.lstrip('/'),posixpath.dirname(member.name))
member.uid = member.gid = 0
member.uname = member.gname = 'root'
return member
archive = BUILD / 'graphics-runtime.tar.gz'
with tarfile.open(archive, 'w:gz', compresslevel=1) as tar:
for path in paths:
tar.add(source / path, arcname=path, filter=keep)
tar.add(ROOT / 'devices/beatles/xorg.conf', arcname='etc/X11/xorg.conf')
link = tarfile.TarInfo('bin/sh')
link.type = tarfile.SYMTYPE
link.linkname = 'dash'
tar.addfile(link)
with archive.open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
record = {'sha256': digest, 'bytes': archive.stat().st_size,
'containsGecko': False, 'device': 'beatles'}
(BUILD / 'graphics-runtime.json').write_text(json.dumps(record, indent=2) + '\n')
print(json.dumps(record))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Decode a private GIF test into bounded RGB565 frames for the cover panel."""
import argparse
from array import array
from pathlib import Path
import struct
import sys
from PIL import Image, ImageOps
def prepare(source, destination):
if source.stat().st_size > 16 * 1024 * 1024:
raise ValueError('GIF exceeds 16 MiB')
with Image.open(source) as image:
if image.format != 'GIF' or not 1 <= image.n_frames <= 240:
raise ValueError('Expected a GIF with at most 240 frames')
if image.width * image.height > 4_000_000:
raise ValueError('GIF dimensions exceed limit')
with destination.open('wb') as output:
output.write(b'VULPANM1' + struct.pack('<I', image.n_frames))
for index in range(image.n_frames):
image.seek(index)
duration = max(50, min(1000, int(image.info.get('duration', 100))))
fitted = ImageOps.contain(image.convert('RGB'), (240, 240), Image.Resampling.LANCZOS)
frame = Image.new('RGB', (240, 240))
frame.paste(fitted, ((240-fitted.width)//2, (240-fitted.height)//2))
rgb=frame.tobytes()
pixels = array('H', (((r>>3)<<11) | ((g>>2)<<5) | (b>>3)
for r,g,b in zip(rgb[0::3],rgb[1::3],rgb[2::3])))
if sys.byteorder != 'little': pixels.byteswap()
output.write(struct.pack('<I', duration)); output.write(pixels.tobytes())
return destination.stat().st_size
if __name__ == '__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('source',type=Path);parser.add_argument('destination',type=Path)
args=parser.parse_args()
print('Cover animation bytes:',prepare(args.source,args.destination))

View file

@ -0,0 +1,166 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage a private Debian ARMhf Firefox experiment; does not contact a phone.
Extracts authenticated packages without running their installation scripts.
This is a diagnostic runtime, not an installable Debian system or Vulpes ROM.
"""
import hashlib
import argparse
import json
import lzma
import os
from pathlib import Path
import re
import subprocess
import tarfile
import urllib.request
ROOT = Path(__file__).resolve().parents[1] / 'out/beatles/gecko-armhf'
def relative_sysroot_links(root):
changed = []
for path in root.rglob('*'):
if not path.is_symlink():
continue
target = path.readlink()
if not target.is_absolute():
continue
relative = os.path.relpath(root / str(target).lstrip('/'), path.parent)
path.unlink()
path.symlink_to(relative)
changed.append(str(path.relative_to(root)))
return changed
def fetch(url, path, expected=None):
if not path.exists():
with urllib.request.urlopen(url, timeout=60) as response:
path.write_bytes(response.read())
data = path.read_bytes()
if expected and hashlib.sha256(data).hexdigest() != expected:
raise RuntimeError('SHA256 mismatch: ' + str(path))
return data
def fields(text):
result = {}
key = None
for line in text.splitlines():
if line.startswith(' ') and key:
result[key] += '\n' + line
elif ': ' in line:
key, value = line.split(': ', 1)
result[key] = value
return result
def main():
global ROOT
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=ROOT)
parser.add_argument('--development', action='store_true', help='Stage ARMhf headers/libraries for cross-compilation')
parser.add_argument('--package', action='append', default=[], help='Additional authenticated ARMhf package')
args = parser.parse_args()
ROOT = args.output.resolve()
for sub in ('metadata', 'packages', 'rootfs'):
(ROOT / sub).mkdir(parents=True, exist_ok=True)
metadata = ROOT / 'metadata'
keys = b''
for key in ('archive-key-11-security', 'archive-key-12', 'archive-key-12-security', 'release-12',
'archive-key-13', 'archive-key-13-security', 'release-13'):
keys += fetch('https://ftp-master.debian.org/keys/' + key + '.asc', metadata / (key + '.asc'))
keyring = metadata / 'debian.gpg'
keyring.write_bytes(subprocess.check_output(['gpg', '--batch', '--dearmor'], input=keys))
packages = {}
for label, base, suite in (
('base', 'https://deb.debian.org/debian', 'bookworm'),
('security', 'https://security.debian.org/debian-security', 'bookworm-security'),
):
release = metadata / (label + '-InRelease')
data = fetch(base + '/dists/' + suite + '/InRelease', release)
subprocess.run(['gpgv', '--keyring', str(keyring), str(release)], check=True)
relative = 'main/binary-armhf/Packages.xz'
section = data.decode().split('\nSHA256:\n', 1)[1]
expected = next(line.split()[0] for line in section.splitlines()
if len(line.split()) == 3 and line.split()[2] == relative)
index = fetch(base + '/dists/' + suite + '/' + relative,
metadata / (label + '-Packages.xz'), expected)
for paragraph in lzma.decompress(index).decode().split('\n\n'):
entry = fields(paragraph)
if 'Package' in entry:
entry['_base'] = base
packages[entry['Package']] = entry
# One release plus its security pocket. Only mandatory dependencies;
# preserve the chosen closure so dependency choices can be audited.
providers = {}
for name, entry in packages.items():
for provided in entry.get('Provides', '').split(','):
virtual = provided.strip().split(' ')[0]
providers.setdefault(virtual, name)
selected = {}
def choose(name):
if name in selected:
return
entry = packages[name]
selected[name] = entry
for key in ('Pre-Depends', 'Depends'):
for group in entry.get(key, '').split(','):
if not group.strip():
continue
alternatives = [re.split(r'[ :(]', alt.strip(), maxsplit=1)[0]
for alt in group.split('|')]
candidate = next((alt if alt in packages else providers.get(alt)
for alt in alternatives if alt in packages or alt in providers), None)
if not candidate:
raise RuntimeError('Unresolved dependency: ' + group)
choose(candidate)
if args.development:
for name in ('libgtk-3-dev', 'libdbus-glib-1-dev', 'libasound2-dev',
'libpulse-dev', 'libx11-xcb-dev', 'libdrm-dev', 'libgbm-dev',
'libxt-dev', 'libstdc++-12-dev', 'libc6-dev', 'libzstd-dev',
'zlib1g-dev', 'libgcc-12-dev'):
choose(name)
else:
choose('firefox-esr')
choose('fonts-dejavu-core')
for name in args.package:
choose(name)
lock = {'purpose': 'ARMv7 compatibility experiment, no ROM',
'version': selected.get('firefox-esr', {}).get('Version'), 'architecture': 'armhf',
'packageScriptsExecuted': False, 'packages': []}
print('Firefox:', lock['version'], 'packages:', len(selected), flush=True)
for name, entry in sorted(selected.items()):
url = entry['_base'] + '/' + entry['Filename']
archive = ROOT / 'packages' / Path(entry['Filename']).name
fetch(url, archive, entry['SHA256'])
members = subprocess.check_output(['ar', 't', str(archive)], text=True).splitlines()
member = next(m for m in members if m.startswith('data.tar'))
data = subprocess.check_output(['ar', 'p', str(archive), member])
subprocess.run(['bsdtar', '--no-same-owner', '-xf', '-', '-C', str(ROOT / 'rootfs')], input=data, check=True)
lock['packages'].append({'name': name, 'version': entry['Version'],
'sha256': entry['SHA256'], 'url': url,
'depends': entry.get('Depends', '')})
print(name, flush=True)
if args.development:
lock['relativeSysrootLinks'] = relative_sysroot_links(ROOT / 'rootfs')
(ROOT / 'runtime-lock.json').write_text(json.dumps(lock, indent=2) + '\n')
if args.development:
return
with tarfile.open(ROOT / 'runtime.tar.gz', 'w:gz', compresslevel=1) as archive:
for relative in ('lib/arm-linux-gnueabihf', 'lib/ld-linux-armhf.so.3',
'usr/lib/arm-linux-gnueabihf', 'usr/lib/firefox-esr',
'etc/fonts', 'usr/share/fonts', 'usr/share/fontconfig'):
archive.add(ROOT / 'rootfs' / relative, arcname=relative)
with (ROOT / 'runtime.tar.gz').open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
(ROOT / 'runtime-archive.json').write_text(json.dumps({
'sha256': digest, 'bytes': (ROOT / 'runtime.tar.gz').stat().st_size,
'gecko': lock['version'], 'architecture': 'armhf'}, indent=2) + '\n')
if __name__ == '__main__':
main()

View file

@ -16,7 +16,7 @@ import time
import io
import tarfile
from ramdisk_reference import read_newc
from shared_ui import build_payload
from shared_ui import build_payload, compress_archive
from tundra import ROOT, checked_source, cpio, digest, save
@ -51,6 +51,19 @@ def main():
raise ValueError('Missing standalone UI overlay')
files={name:{**item} for name,item in original.items()}
files['shared-ui.tar']['data']=payload
# Older trials stored plain tar archives. Both payloads now use XZ,
# including native-only refreshes, with explicit BusyBox decompression.
import lzma
native_payload = original['native-config.tar']['data']
if native_payload.startswith(b'\xfd7zXZ\x00'):
native_payload = lzma.decompress(native_payload)
files['native-config.tar']['data']=compress_archive(native_payload)
script=files['native-root.sh']['data']
for name in ('native-config.tar','shared-ui.tar'):
script=script.replace(('tar -xf /'+name).encode(),('tar -xJf /'+name).encode())
if ('tar -xJf /'+name).encode() not in script:
raise ValueError('Unrecognized native archive reader: '+name)
files['native-root.sh']['data']=script
native_sources = {}
if args.settings_adapters:
names = ['radio-control.py','wifi-control.py','mobile-settings.py','camera-preview.py']
@ -69,7 +82,7 @@ def main():
for name,link in aliases.items():
info=tarfile.TarInfo(name);info.type=tarfile.SYMTYPE;info.linkname=link;info.mode=0o777
target.addfile(info)
files['native-config.tar']['data']=buffer.getvalue()
files['native-config.tar']['data']=compress_archive(buffer.getvalue())
entries=[]
for name,item in sorted(files.items()):
if (stat.S_ISCHR(item['mode']) or stat.S_ISBLK(item['mode'])) and name!='dev/console':
@ -88,7 +101,7 @@ def main():
verified=read_newc((temp/'verify/ramdisk').read_bytes())
if verified.keys()!=original.keys():raise ValueError('Ramdisk entry list changed')
changed=[name for name in original if (verified[name]['mode'],verified[name]['data'])!=(original[name]['mode'],original[name]['data'])]
permitted={'native-config.tar','shared-ui.tar'} if args.settings_adapters else {'shared-ui.tar'}
permitted={'native-config.tar','shared-ui.tar','native-root.sh'}
if set(changed)-permitted:raise ValueError('Unexpected changes: '+str(changed))
# A native-only fix may reproduce the same shared UI archive exactly.
# Still verify every replacement, including unchanged payloads.

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run a 45-second framebuffer/keypad test, then restore KaiOS automatically."""
import json
from pathlib import Path
import subprocess
import time
from kaios_debug import KaiOSDebugger
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'out/beatles/gecko-156.0.1'
REMOTE = '/data/local/vulpes-beatles'
def main():
rows = subprocess.check_output(['adb','devices','-l'],text=True).splitlines()
devices = [r.split()[0] for r in rows if 'model:Nokia_2720_Flip' in r.split()
and r.split()[1]=='device']
if len(devices)!=1:
raise RuntimeError('Exactly one connected Nokia required')
adb = ['adb','-s',devices[0]]
if subprocess.check_output(adb+['shell','getprop ro.product.codename'],text=True).strip()!='beatles':
raise RuntimeError('Unexpected device')
sysroot=OUT/'sysroot/rootfs'
compiler=[str(OUT/'mozbuild/clang/bin/clang'),'--target=arm-linux-gnueabihf',
'--sysroot='+str(sysroot),'--gcc-install-dir='+str(sysroot/'usr/lib/gcc/arm-linux-gnueabihf/12'),
'-fuse-ld=lld','-march=armv7-a','-mfpu=neon','-mfloat-abi=hard',
'-O2','-Wall','-Wextra','-Werror']
subprocess.run(compiler+['-static',str(ROOT/'probes/beatles-display-session.c'),
'-o',str(OUT/'display-session')],check=True)
subprocess.run(compiler+[str(ROOT/'probes/beatles-x11-test.c'),'-lX11',
'-o',str(OUT/'x11-test')],check=True)
subprocess.run(compiler+[str(ROOT/'probes/beatles-keypad.c'),'-lX11','-lXtst',
'-o',str(OUT/'keypad')],check=True)
(OUT/'display-test.sh').write_text('exec /usr/bin/python3.11 /work/display-test.py\n')
dirs=' '.join(REMOTE+'/rootfs/'+d for d in ('work','proc','sys','dev','tmp','var/log','run'))
subprocess.run(adb+['shell','mkdir -p '+dirs+'; chmod 1777 '+REMOTE+'/rootfs/tmp'],check=True,capture_output=True)
files=((OUT/'display-session','display-session'),(OUT/'x11-test','rootfs/work/x11-test'),
(OUT/'keypad','rootfs/work/keypad'),
(OUT/'display-test.sh','rootfs/work/display-test.sh'),
(ROOT/'devices/beatles/display-test.py','rootfs/work/display-test.py'),
(ROOT/'devices/beatles/xorg.conf','rootfs/etc/X11/xorg.conf'))
for local,remote in files:
subprocess.run(adb+['push',str(local),REMOTE+'/'+remote],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+REMOTE+'/display-session '+REMOTE+'/rootfs/work/x11-test '+REMOTE+'/rootfs/work/keypad; rm -f '
+REMOTE+'/display-session.done'],check=True,capture_output=True)
with KaiOSDebugger(devices[0]) as client:
console=client.request('root','getProcess',id=0)['form']['consoleActor']
script='''(function(){var Cc=Components.classes,Ci=Components.interfaces;
var f=Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);f.initWithPath(%s);
var p=Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);p.init(f);
p.runAsync(["--temporary-45s"],1,null,false);return "started";})()''' % json.dumps(REMOTE+'/display-session')
print(client.evaluate(console,script),flush=True)
for _ in range(65):
state=subprocess.check_output(adb+['shell','cat '+REMOTE+'/display-session.done'],text=True).strip()
if state=='restored':
break
time.sleep(1)
else:
raise RuntimeError('No restoration marker; inspect display-session.log and b2g service')
for relative in ('display-session.log','rootfs/work/Xorg.log','rootfs/work/xorg-console.log',
'rootfs/work/x11-test.log','rootfs/work/x11-test.ppm','rootfs/work/keypad.log'):
subprocess.run(adb+['pull',REMOTE+'/'+relative,str(OUT/Path(relative).name)],capture_output=True)
service=subprocess.check_output(adb+['shell','getprop init.svc.b2g'],text=True).strip()
print((OUT/'display-session.log').read_text())
print('KaiOS service:',service)
if service!='running':
raise RuntimeError('KaiOS did not restart')
if __name__=='__main__':
main()

View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Synchronize the shared host and run a bounded private Nokia Gaia trial."""
import argparse
import json
from pathlib import Path
import shutil
import subprocess
import sys
import time
from kaios_debug import KaiOSDebugger
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'tundra/out/beatles/gecko-156.0.1'
REMOTE='/data/local/vulpes-beatles'
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--interactive',action='store_true',help='Manual session, no automatic keypad actions; KaiOS returns within 30 minutes')
parser.add_argument('--minutes',type=int,choices=(15,30),default=30,help='Manual session duration (default: 30 minutes)')
parser.add_argument('--cover-gif',type=Path,help='Private cover animation test, eight seconds on each screen wake')
parser.add_argument('--phone',action='store_true',help='Enable Nokia radio, Wi-Fi, SMS/call and hardware control brokers')
parser.add_argument('--camera-probe',action='store_true',help='Run the bounded Qualcomm HAL preview probe before Gaia starts')
parser.add_argument('--camera-preview-binary',type=Path,help='Use a private QCamera preview-dump binary for a bounded RAM-only trial')
args=parser.parse_args()
session_option=(('--phone-' if args.phone else '--interactive-')+str(args.minutes*60)+'s') if args.interactive else ('--phone-180s' if args.phone else '--temporary-90s' if (args.camera_probe or args.camera_preview_binary) else '--temporary-180s')
rows=subprocess.check_output(['adb','devices','-l'],text=True).splitlines()
devices=[r.split()[0] for r in rows if 'model:Nokia_2720_Flip' in r.split() and r.split()[1]=='device']
if len(devices)!=1:raise RuntimeError('Exactly one connected Nokia required')
adb=['adb','-s',devices[0]]
if subprocess.check_output(adb+['shell','getprop ro.product.codename'],text=True).strip()!='beatles':
raise RuntimeError('Unexpected device')
if not args.interactive:
lid=subprocess.check_output(adb+['shell','cat /sys/devices/soc.0/hall_sensor1.73/hall_status'],text=True).strip()
if lid=='1' and not (args.camera_probe or args.camera_preview_binary):
raise RuntimeError('Open the Nokia flip before running keypad qualification; no session started')
ini=subprocess.check_output(adb+['shell','cat '+REMOTE+'/rootfs/opt/firefox/application.ini'],text=True)
if 'Version=156.0.1' not in ini.splitlines():raise RuntimeError('Stage Gecko 156.0.1 before launching')
subprocess.run([sys.executable,str(ROOT/'tundra/tools/sync-beatles-gaia.py'),'--serial',devices[0]],check=True)
if args.cover_gif:
packed=OUT/'cover-animation.rgb565'
subprocess.run([sys.executable,str(ROOT/'tundra/tools/prepare-beatles-cover-animation.py'),str(args.cover_gif),str(packed)],check=True)
subprocess.run(adb+['push',str(packed),REMOTE+'/rootfs/work/cover-animation.rgb565'],check=True,capture_output=True)
else:
subprocess.run(adb+['shell','rm -f '+REMOTE+'/rootfs/work/cover-animation.rgb565'],check=True,capture_output=True)
sysroot=OUT/'sysroot/rootfs'
compiler=[str(OUT/'mozbuild/clang/bin/clang'),'--target=arm-linux-gnueabihf',
'--sysroot='+str(sysroot),'--gcc-install-dir='+str(sysroot/'usr/lib/gcc/arm-linux-gnueabihf/12'),
'-fuse-ld=lld','-march=armv7-a','-mfpu=neon','-mfloat-abi=hard','-O2','-Wall','-Wextra','-Werror']
subprocess.run(compiler+['-static',str(ROOT/'tundra/probes/beatles-control-client.c'),'-o',str(OUT/'control-client')],check=True)
subprocess.run(compiler+['-static',str(ROOT/'tundra/probes/beatles-display-session.c'),'-o',str(OUT/'display-session')],check=True)
subprocess.run(compiler+[str(ROOT/'tundra/probes/beatles-keypad.c'),'-lX11','-lXtst','-o',str(OUT/'keypad')],check=True)
(OUT/'gaia-test.sh').write_text('exec /usr/bin/python3.11 /work/gaia-session.py\n')
dirs=' '.join(REMOTE+'/rootfs/'+d for d in ('opt/firefox/vulpes/tests','opt/firefox/defaults/pref','work','proc','sys','dev','tmp','run'))
subprocess.run(adb+['shell','mkdir -p '+dirs+'; chmod 1777 '+REMOTE+'/rootfs/tmp; rm -f '+REMOTE+'/display-session.done '+REMOTE+'/rootfs/work/gaia-audit.json'],capture_output=True,check=True)
subprocess.run(adb+['shell','mkdir -p '+REMOTE+'/rootfs/opt/vulpes/tundra/devices/beatles'],capture_output=True,check=True)
if args.camera_probe:
subprocess.run(adb+['shell','touch '+REMOTE+'/qcamera-probe-once'],check=True,capture_output=True)
if args.camera_preview_binary:
if not args.camera_preview_binary.is_file():raise RuntimeError('QCamera preview probe binary not found')
# The trial output lives below the persistent device root. Clear it
# before each run so a failed camera launch cannot masquerade as a
# freshly captured frame from an earlier probe.
subprocess.run(adb+['shell','rm -f '+REMOTE+'/qcamera-preview-dump '+REMOTE+'/qcamera-preview-dump-once '+REMOTE+'/qcamera-probe.log '+REMOTE+'/rootfs/work/camera-frame.yuv '+REMOTE+'/rootfs/work/camera-photo.jpg'],check=True,capture_output=True)
subprocess.run(adb+['push',str(args.camera_preview_binary),REMOTE+'/qcamera-preview-dump'],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+REMOTE+'/qcamera-preview-dump; touch '+REMOTE+'/qcamera-preview-dump-once'],check=True,capture_output=True)
subprocess.run(adb+['push',str(ROOT/'tundra/devices/beatles/t9'),REMOTE+'/rootfs/opt/vulpes/tundra/devices/beatles/'],check=True,capture_output=True,timeout=120)
# Seed the isolated Gaia media store with small, known-good playback samples.
media=REMOTE+'/rootfs/work/gaia-profile/vulpes-media'
subprocess.run(adb+['shell','mkdir -p '+media+'/music '+media+'/videos'],check=True,capture_output=True)
samples=((ROOT/'assets/webapps/uitest.gaiamobile.org/data/audio/jonobacon-freesoftwaresong2.ogg',
media+'/music/Vulpes-test-track.ogg'),
(ROOT/'assets/webapps/uitest.gaiamobile.org/data/video/meetthecubs.webm',
media+'/videos/Vulpes-test-video.webm'))
for local,remote in samples:
subprocess.run(adb+['push',str(local),remote],check=True,capture_output=True,timeout=60)
# Use the phone's configured zone, not the build PC's local timezone.
zone=subprocess.check_output(adb+['shell','getprop persist.sys.timezone'],text=True).strip()
zone_root=Path('/usr/share/zoneinfo').resolve()
zone_path=(zone_root/zone).resolve()
if not zone or not zone_path.is_relative_to(zone_root) or not zone_path.is_file():
zone='UTC';zone_path=zone_root/'UTC'
subprocess.run(adb+['push',str(zone_path),REMOTE+'/rootfs/etc/localtime'],check=True,capture_output=True)
print('Device timezone:',zone,flush=True)
for directory in ('host','services','adapters'):
subprocess.run(adb+['push',str(ROOT/directory),REMOTE+'/rootfs/opt/firefox/vulpes/'],check=True,capture_output=True,timeout=120)
subprocess.run(adb+['push',str(ROOT/'host'),REMOTE+'/rootfs/opt/vulpes/'],check=True,capture_output=True,timeout=120)
files=((OUT/'control-client','rootfs/work/control-client'),(OUT/'display-session','display-session'),(OUT/'keypad','rootfs/work/keypad'),
(OUT/'gaia-test.sh','rootfs/work/display-test.sh'),
(ROOT/'tundra/devices/beatles/gaia-session.py','rootfs/work/gaia-session.py'),
(ROOT/'tundra/devices/beatles/cover-display.py','rootfs/work/cover-display.py'),
(ROOT/'tundra/devices/beatles/radio_control.py','rootfs/work/radio_control.py'),
(ROOT/'tundra/devices/beatles/call_audio.py','rootfs/work/call_audio.py'),
(ROOT/'tundra/devices/beatles/stream_volume.py','rootfs/work/stream_volume.py'),
(ROOT/'tundra/devices/beatles/hardware_control.py','rootfs/work/hardware_control.py'),
(ROOT/'tundra/devices/beatles/wifi_control.py','rootfs/work/wifi_control.py'),
(ROOT/'tundra/devices/beatles/sms_pdu.py','rootfs/work/sms_pdu.py'),
(ROOT/'tundra/devices/beatles/radio_status.py','rootfs/work/radio_status.py'),
(ROOT/'tundra/devices/beatles/gaia-audit.sys.mjs','rootfs/opt/firefox/vulpes/tests/BeatlesAudit.sys.mjs'),
(ROOT/'tundra/devices/beatles/xorg.conf','rootfs/etc/X11/xorg.conf'),
(ROOT/'tests/ProbeChild.sys.mjs','rootfs/opt/firefox/vulpes/tests/ProbeChild.sys.mjs'))
for local,remote in files:
subprocess.run(adb+['push',str(local),REMOTE+'/'+remote],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+REMOTE+'/display-session '+REMOTE+'/rootfs/work/keypad '+REMOTE+'/rootfs/work/control-client'],check=True,capture_output=True)
deadline=time.monotonic()+90
notice=0
while True:
try:
with KaiOSDebugger(devices[0]) as client:
console=client.request('root','getProcess',id=0)['form']['consoleActor']
# A previous session may finish while we wait for the debugger.
# Its restoration marker must not be mistaken for this trial.
subprocess.run(adb+['shell','rm -f '+REMOTE+'/display-session.done'],check=True,capture_output=True)
script='''(function(){var Cc=Components.classes,Ci=Components.interfaces;
var f=Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);f.initWithPath(%s);
var p=Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);p.init(f);
p.runAsync([%s],1,null,false);return "started";})()''' % (json.dumps(REMOTE+'/display-session'),json.dumps(session_option))
print(client.evaluate(console,script),flush=True)
break
except (EOFError,OSError):
if time.monotonic()>=deadline:raise
if time.monotonic()>=notice:
print('Waiting for KaiOS debugger after restart...',flush=True)
notice=time.monotonic()+15
time.sleep(3)
if args.interactive:
for second in range(100):
state=subprocess.check_output(adb+['shell','cat '+REMOTE+'/display-session.done'],text=True).strip()
if state=='restored':raise RuntimeError('Session ended before Gaia was ready; inspect display-session.log and work/keypad.log')
raw=subprocess.check_output(adb+['shell','cat '+REMOTE+'/rootfs/work/gaia-audit.json'],text=True).strip()
try: audit=json.loads(raw)
except json.JSONDecodeError: audit={}
if audit.get('error'):raise RuntimeError(audit['error'])
if audit.get('readyMs') and audit.get('engine')=='156.0.1':
print(f'Gaia ready for manual testing; automatic KaiOS return within {args.minutes} minutes.',flush=True)
print('Radio:',json.dumps(audit.get('radioAPI',{})),flush=True)
print('Stop early: adb shell touch '+REMOTE+'/stop-session',flush=True)
return
if second and second%20==0:print('Waiting for Gaia...',flush=True)
time.sleep(1)
subprocess.run(adb+['shell','touch '+REMOTE+'/stop-session'],check=True,capture_output=True)
raise RuntimeError('Manual session startup timed out; requested KaiOS restoration')
for second in range(205):
state=subprocess.check_output(adb+['shell','cat '+REMOTE+'/display-session.done'],text=True).strip()
if state=='restored':break
if second and second%30==0:print('Trial running:',second,'seconds',flush=True)
time.sleep(1)
else:raise RuntimeError('No restoration marker; inspect the watchdog before retrying')
report=OUT/('gaia-trial-'+time.strftime('%Y%m%d-%H%M%S'))
report.mkdir()
for relative in ('display-session.log','qcamera-probe.log','framebuffer.ppm','rootfs/work/camera-frame.yuv','rootfs/work/camera-photo.jpg','rootfs/work/Xorg.log','rootfs/work/xorg-console.log',
'rootfs/run/tundra-hardware/status.json','rootfs/work/radio-status.log',
'rootfs/work/cover-display.log','rootfs/work/cover-preview.png','rootfs/work/cover-refresh.json','rootfs/work/lid-state.json',
'rootfs/work/keypad.log','rootfs/work/gecko.log','rootfs/work/gaia-server.log',
'rootfs/work/gaia-audit.json','rootfs/work/gaia-native.png','rootfs/work/memory-samples.jsonl'):
subprocess.run(adb+['pull',REMOTE+'/'+relative,str(report/Path(relative).name)],capture_output=True)
service=subprocess.check_output(adb+['shell','getprop init.svc.b2g'],text=True).strip()
print('KaiOS service:',service,'Report:',report,flush=True)
# Keep both outputs from the bounded Qualcomm probe. Its still-snapshot
# YUV is NV21 1280x960; decode it for inspection and compare it with the
# vendor JPEG without changing anything on the phone.
raw=report/'camera-frame.yuv'
if raw.is_file() and raw.stat().st_size == 1280*960*3//2 and shutil.which('ffmpeg'):
source=['ffmpeg','-y','-loglevel','error','-f','rawvideo','-pixel_format','nv21',
'-video_size','1280x960','-i',str(raw),'-frames:v','1']
subprocess.run(source+[str(report/'camera-frame.png')],check=False,capture_output=True)
subprocess.run(source+['-q:v','3',str(report/'camera-frame-encoded.jpg')],
check=False,capture_output=True)
if service!='running':raise RuntimeError('KaiOS did not restart')
if args.camera_preview_binary:
subprocess.run(adb+['shell','rm -f '+REMOTE+'/qcamera-preview-dump '+REMOTE+'/qcamera-preview-dump-once'],capture_output=True)
if not (report/'gaia-audit.json').is_file():raise RuntimeError('Gaia audit did not complete')
audit=json.loads((report/'gaia-audit.json').read_text())
summary={key:value for key,value in audit.items() if key not in ('icons','layout')}
print(json.dumps(summary,indent=2),flush=True)
radio=audit.get('hardware',{}).get('radio',[])
qualification={
'scope':'Gaia display and keypad; no SMS or call sent',
'radioStatusAvailable':bool(radio) and all(slot.get('available') for slot in radio),
'smsNetworkTested':False,'callsTested':False,
}
(report/'hardware-qualification.json').write_text(json.dumps(qualification,indent=2)+'\n')
print('Radio status available:',qualification['radioStatusAvailable'],flush=True)
icons=audit.get('icons',[])
decoded=len(icons)>=4 and all(any(image.get('complete') and image.get('width',0)>0
for image in icon.get('images',[])) for icon in icons[:4])
if (audit.get('error') or audit.get('engine')!='156.0.1' or not audit.get('homescreen',{}).get('icons')
or not audit.get('logoHidden') or not audit.get('smsOpened') or audit.get('keypadInput')!='2'
or (audit.get('layout',{}).get('mode')=='compact' and audit.get('selectedAppName') is not True)
or not all(audit.get(name) for name in ('multitap','inputModes','deleteText','smsRecipient','smsBody'))
or (args.phone and (not audit.get('invalidSmsRejected') or not audit.get('phoneBroker',{}).get('ready')
or not audit.get('dialerDigits') or not audit.get('dialerClear')))
or not audit.get('returnHome') or not decoded or not (report/'framebuffer.ppm').is_file()):
raise RuntimeError('Gaia qualification failed; inspect the report')
if __name__=='__main__':main()

View file

@ -0,0 +1,75 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run the offline Gecko probe in a previously staged private Nokia runtime."""
import argparse
import json
from pathlib import Path
import subprocess
import time
from kaios_debug import KaiOSDebugger
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'out/beatles/gecko-armhf'
REMOTE = '/data/local/tmp/vulpes-gecko-armhf'
def main():
global OUT, REMOTE
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=('version', 'render'))
parser.add_argument('--engine', choices=('153.5.0esr', '156.0.1'), default='153.5.0esr')
args = parser.parse_args()
if args.engine == '156.0.1':
OUT = ROOT / 'out/beatles/gecko-156.0.1'
REMOTE = '/data/local/vulpes-beatles'
rows = subprocess.check_output(['adb', 'devices', '-l'], text=True).splitlines()
devices = [r.split()[0] for r in rows if len(r.split()) > 2
and r.split()[1] == 'device' and 'model:Nokia_2720_Flip' in r.split()]
if len(devices) != 1:
raise RuntimeError('Exactly one connected Nokia is required')
adb = ['adb', '-s', devices[0]]
if subprocess.check_output(adb + ['shell', 'getprop ro.product.codename'], text=True).strip() != 'beatles':
raise RuntimeError('Unexpected device')
command = ['clang', '--target=armv7-linux-gnueabihf', '-march=armv7-a',
'-marm', '-mfloat-abi=soft', '-fuse-ld=lld', '-Os', '-ffreestanding',
'-fno-builtin', '-fno-stack-protector', '-fno-pie', '-fno-unwind-tables',
'-fno-asynchronous-unwind-tables', '-Wall', '-Wextra', '-Werror',
'-nostdlib', '-static', '-Wl,--build-id=none', '-Wl,-e,_start',
str(ROOT / 'probes/armv7-gecko-launch.c'), '-o', str(OUT / 'gecko-probe-launcher')]
if args.engine == '156.0.1':
command += ['-DROOT="' + REMOTE + '/rootfs"', '-DGECKO="/opt/firefox/firefox"']
subprocess.run(command, check=True)
subprocess.run(adb + ['shell', 'mkdir -p ' + REMOTE + '/rootfs/work'], check=True, capture_output=True)
for local, relative in ((OUT / 'gecko-probe-launcher', 'gecko-probe-launcher'),
(ROOT / 'devices/beatles/run-gecko-test.sh', 'probe-run.sh'),
(ROOT / 'devices/beatles/gecko-test.html', 'rootfs/work/test.html')):
subprocess.run(adb + ['push', str(local), REMOTE + '/' + relative], check=True, capture_output=True)
subprocess.run(adb + ['shell', 'chmod 755 ' + REMOTE + '/gecko-probe-launcher; rm -f '
+ REMOTE + '/' + args.mode + '.exit'], check=True, capture_output=True)
with KaiOSDebugger(devices[0]) as client:
console = client.request('root', 'getProcess', id=0)['form']['consoleActor']
script_args = [REMOTE + '/probe-run.sh', args.mode, args.engine]
script = '''(function(){var Cc=Components.classes,Ci=Components.interfaces;
var f=Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);f.initWithPath("/system/bin/sh");
var p=Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);p.init(f);
var args=%s;p.runAsync(args,args.length,null,false);return "started";})()''' % json.dumps(script_args)
print(client.evaluate(console, script), flush=True)
for _ in range(75):
result = subprocess.check_output(adb + ['shell', 'cat ' + REMOTE + '/' + args.mode + '.exit'], text=True).strip()
if result.isdigit():
log = subprocess.check_output(adb + ['shell', 'cat ' + REMOTE + '/' + args.mode + '.log'], text=True)
(OUT / (args.mode + '.log')).write_text(log)
(OUT / (args.mode + '-result.json')).write_text(json.dumps({'exitCode': int(result),
'mode': args.mode, 'device': 'beatles', 'engine': args.engine, 'offline': True}, indent=2) + '\n')
print(log, flush=True)
print('Exit:', result, flush=True)
if result == '0' and args.mode == 'render':
subprocess.run(adb + ['pull', REMOTE + '/rootfs/work/gecko.png', str(OUT / 'gecko.png')],
check=True, capture_output=True)
raise SystemExit(int(result))
time.sleep(1)
raise RuntimeError('No completion marker; inspect owned diagnostic process before retrying')
if __name__ == '__main__':
main()

View file

@ -1,12 +1,53 @@
# SPDX-License-Identifier: MPL-2.0
"""Shared Gaia payload used by full builds and private UI-only RAM updates."""
import io
import importlib.util
import lzma
from pathlib import Path
import stat
import tarfile
from tundra import ROOT, digest
def differential_payload(payload, system_root):
"""Keep only files not already identical in the immutable system image."""
root = Path(system_root).resolve()
output = io.BytesIO()
omitted = 0
with tarfile.open(fileobj=io.BytesIO(payload)) as source, tarfile.open(fileobj=output, mode='w') as target:
for item in source:
path = root / item.name
if item.name.startswith('/') or '..' in Path(item.name).parts:
raise ValueError('Unsafe shared UI path: ' + item.name)
same = False
data = source.extractfile(item).read() if item.isfile() else None
if path.parent.resolve().is_relative_to(root):
if item.isfile() and path.is_file() and not path.is_symlink():
same = path.read_bytes() == data and stat.S_IMODE(path.stat().st_mode) == item.mode
elif item.issym() and path.is_symlink():
same = str(path.readlink()) == item.linkname
if same:
omitted += 1
else:
target.addfile(item, io.BytesIO(data) if data is not None else None)
return compress_archive(output.getvalue()), omitted
def compress_archive(payload):
"""Keep the language dictionaries within Sargo's 64 MiB boot partition.
The ramdisk uses BusyBox tar -xJf; the outer cpio remains gzip for the kernel.
"""
return lzma.compress(payload, preset=9)
def selected_files():
return [*sorted((ROOT.parent/'host').rglob('*')),
spec = importlib.util.spec_from_file_location('vulpes_project', ROOT.parent/'tools/project.py')
project = importlib.util.module_from_spec(spec)
spec.loader.exec_module(project)
sources = [ROOT.parent/'gaia/apps'/app/name
for app, names in project.SOURCE_OVERRIDES.items() for name in names]
return [*sources,*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT/'mms-codec').glob('*')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
@ -16,6 +57,8 @@ def selected_files():
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/dialer/js/call_log.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/dialer/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js',
@ -25,7 +68,6 @@ def selected_files():
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
ROOT.parent/'gaia/apps/communications/dialer/js/telephony_helper.js',
ROOT.parent/'tools/project.py',
ROOT.parent/'tools/serve-gaia.py',
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
@ -46,6 +88,10 @@ def build_payload():
continue
relative = str(path.relative_to(ROOT.parent))
target = 'trial/' + path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
if path.suffix.lower() == '.md':
if path.stem.lower() not in ('license', 'copying', 'notice'):
continue
target = target[:-3]
archive.add(path, arcname='opt/vulpes/' + target, filter=owned)
hashes[relative] = digest(path)
return buffer.getvalue(), hashes
return compress_archive(buffer.getvalue()), hashes

View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Verify/stage the private Nokia runtime. Does not install a boot image."""
import argparse
import hashlib
import json
from pathlib import Path
import shlex
import posixpath
import subprocess
import tarfile
from kaios_debug import KaiOSDebugger
REMOTE='/data/local/vulpes-beatles'
def digest(path):
with path.open('rb') as stream:return hashlib.file_digest(stream,'sha256').hexdigest()
def check(folder):
build=json.loads((folder/'build.json').read_text())
runtime=json.loads((folder/'runtime.json').read_text())
if build['device']!='beatles' or runtime['runtimePath']!=REMOTE:raise ValueError('Unexpected device/runtime')
if digest(folder/build['image'])!=build['sha256']:raise ValueError('Boot image checksum mismatch')
order=['graphics-runtime.tar.gz','engine-runtime.tar.gz','gaia-shared.tar.gz','trial-overlay.tar.gz']
if runtime['archivesInExtractionOrder']!=order:raise ValueError('Unexpected extraction order')
for name in order:
record=runtime['archives'][name];path=folder/name
if path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:raise ValueError('Archive mismatch: '+name)
with tarfile.open(path) as archive:
for item in archive:
p=Path(item.name)
if p.is_absolute() or '..' in p.parts or item.isdev():raise ValueError('Unsafe archive member')
if item.issym() or item.islnk():
target=posixpath.normpath(posixpath.join(posixpath.dirname(item.name) if item.issym() else '',item.linkname))
if target.startswith(('/', '../')) or target=='..':raise ValueError('Archive link escapes runtime')
if any(part in ('gaia-profile','profiles','logs') for part in p.parts):raise ValueError('Profile/log included in runtime')
return build,runtime
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
parser.add_argument('--check',action='store_true',help='Validate local artifacts only')
parser.add_argument('--update',action='store_true',help='Update an existing runtime, preserving its profile')
args=parser.parse_args();folder=args.build.resolve();build,runtime=check(folder)
if args.check:print('Boot and runtime checksums/archive layout: OK');return
lines=subprocess.check_output(['adb','devices','-l'],text=True).splitlines()
serials=[r.split()[0] for r in lines if 'model:Nokia_2720_Flip' in r.split() and r.split()[1]=='device']
if len(serials)!=1:raise ValueError('Exactly one connected Nokia required')
adb=['adb','-s',serials[0]]
def shell(command):return subprocess.check_output(adb+['shell',command],text=True,timeout=900).strip()
if shell('getprop ro.product.codename')!='beatles' or shell('getprop ro.build.version.incremental')!=build['firmware']:
raise ValueError('Unsupported phone/firmware')
if shell('getprop init.svc.b2g')!='running':raise ValueError('Stop the existing Vulpes trial first')
exists=shell('test -d '+REMOTE+' && echo EXISTS')=='EXISTS'
if exists and not args.update:raise ValueError('Runtime exists; use --update to preserve and update it')
if not exists:
with KaiOSDebugger(serials[0]) as client:
console=client.request('root','getProcess',id=0)['form']['consoleActor']
# ADB stages this private diagnostic tree; no persistent root service is installed.
js='''(function(){var f=Components.classes['@mozilla.org/file/local;1'].createInstance(Components.interfaces.nsIFile);f.initWithPath(%s);if(f.exists())throw Error('Already exists');f.create(1,493);f.permissions=493;var exe=Components.classes['@mozilla.org/file/local;1'].createInstance(Components.interfaces.nsIFile);exe.initWithPath('/system/bin/chown');var process=Components.classes['@mozilla.org/process/util;1'].createInstance(Components.interfaces.nsIProcess);process.init(exe);var args=['2000:2000',f.path];process.run(true,args,args.length);return process.exitValue===0;})()''' % json.dumps(REMOTE)
if client.evaluate(console,js) is not True:raise ValueError('Cannot prepare runtime directory')
if shell('mkdir -p '+REMOTE+'/rootfs && rm -f '+REMOTE+'/runtime-ready && echo READY')!='READY':
raise ValueError('Cannot prepare runtime')
for name in runtime['archivesInExtractionOrder']:
remote=REMOTE+'/'+name;print('Staging '+name,flush=True)
subprocess.run(adb+['push',str(folder/name),remote],check=True,capture_output=True,timeout=900)
if shell('/system/bin/busybox sha256sum '+shlex.quote(remote)).split()[0]!=runtime['archives'][name]['sha256']:
raise ValueError('Phone transfer checksum mismatch')
if not shell('/system/bin/busybox tar -xzf '+shlex.quote(remote)+' -C '+REMOTE+'/rootfs && echo EXTRACTED').endswith('EXTRACTED'):
raise ValueError('Runtime extraction failed')
shell('rm '+shlex.quote(remote))
version=shell('cat '+REMOTE+'/rootfs/opt/firefox/application.ini')
if 'Version=156.0.1' not in version.splitlines():raise ValueError('Wrong staged engine')
shell('touch '+REMOTE+'/runtime-ready')
print('Private runtime staged. Boot partition unchanged; image still requires a qualified boot route.')
if __name__=='__main__':main()

View file

@ -0,0 +1,70 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage the locally built ARMv7 Gecko in the Nokia's disposable runtime."""
import hashlib
import json
from pathlib import Path
import subprocess
import tarfile
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'out/beatles/gecko-156.0.1'
REMOTE = '/data/local/vulpes-beatles'
def main():
packages = list((OUT / 'obj-armv7/dist').glob('firefox-156.0.1.*.tar.*'))
if len(packages) != 1:
raise RuntimeError('Run build-beatles-gecko.sh package first; expected one release archive')
destination = OUT / 'engine-runtime.tar.gz'
with tarfile.open(packages[0]) as source:
application = source.extractfile('firefox/application.ini').read().decode()
platform = source.extractfile('firefox/platform.ini').read().decode()
executable = source.extractfile('firefox/firefox').read(20)
if 'Version=156.0.1' not in application.splitlines():
raise RuntimeError('Unexpected Gecko version')
if executable[:6] != b'\x7fELF\x01\x01' or int.from_bytes(executable[18:20], 'little') != 40:
raise RuntimeError('Expected a 32-bit little-endian ARM executable')
with tarfile.open(destination, 'w:gz', compresslevel=1) as target:
for member in source:
if member.name != 'firefox' and not member.name.startswith('firefox/'):
raise RuntimeError('Unexpected archive layout')
member.name = 'opt/' + member.name
if member.islnk():
member.linkname = 'opt/' + member.linkname
target.addfile(member, source.extractfile(member) if member.isfile() else None)
with destination.open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
rows = subprocess.check_output(['adb', 'devices', '-l'], text=True).splitlines()
devices = [r.split()[0] for r in rows if 'model:Nokia_2720_Flip' in r.split() and r.split()[1] == 'device']
if len(devices) != 1:
raise RuntimeError('Exactly one connected Nokia required')
adb = ['adb', '-s', devices[0]]
def shell(command):
return subprocess.check_output(adb + ['shell', command], text=True).strip()
if shell('getprop ro.product.codename') != 'beatles' or shell('getprop init.svc.b2g') != 'running':
raise RuntimeError('Expected beatles running KaiOS before staging')
print('Uploading Gecko 156.0.1 ARMv7:', destination.stat().st_size, 'bytes', flush=True)
subprocess.run(adb + ['push', str(destination), REMOTE + '/engine-runtime.tar.gz'],
check=True, capture_output=True, timeout=900)
actual = shell('/system/bin/busybox sha256sum ' + REMOTE + '/engine-runtime.tar.gz').split()[0]
if actual != digest:
raise RuntimeError('Phone archive hash mismatch')
result = shell('/system/bin/busybox tar -xzf ' + REMOTE + '/engine-runtime.tar.gz -C ' +
REMOTE + '/rootfs && echo EXTRACTED')
if not result.endswith('EXTRACTED'):
raise RuntimeError('Engine extraction failed: ' + result)
if shell('cat ' + REMOTE + '/rootfs/opt/firefox/application.ini').splitlines() != application.strip().splitlines():
raise RuntimeError('Staged application.ini mismatch')
shell('rm ' + REMOTE + '/engine-runtime.tar.gz')
record = {'version': '156.0.1', 'architecture': 'armv7', 'device': 'beatles',
'archiveSha256': digest, 'archiveBytes': destination.stat().st_size,
'source': json.loads((OUT / 'source.json').read_text()),
'applicationIni': application, 'platformIni': platform,
'phoneArchiveHashVerified': True, 'flashed': False}
(OUT / 'engine-staging.json').write_text(json.dumps(record, indent=2) + '\n')
print('Gecko staged and verified. No system partition changed.', flush=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Synchronize the shared Gaia payload into an idle private Nokia runtime."""
import argparse
import hashlib
import importlib.util
import json
from pathlib import Path, PurePosixPath
import shlex
import subprocess
import tarfile
ROOT=Path(__file__).resolve().parents[2]
REMOTE='/data/local/vulpes-beatles/rootfs'
PREFIXES=('assets/','host/','services/','adapters/','overrides/','gaia/shared/','gaia/apps/')
FILES={'release.json','engine-lock.json','tools/project.py','tools/serve-gaia.py','tools/installed_apps.py'}
def managed(path):
p=PurePosixPath(path)
return not p.is_absolute() and '..' not in p.parts and (path in FILES or path.startswith(PREFIXES))
def changes(current, previous):
if not isinstance(previous,dict) or any(not managed(p) for p in previous):
raise ValueError('Invalid previous Gaia inventory')
return sorted(p for p,digest in current.items() if previous.get(p)!=digest), sorted(set(previous)-set(current))
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--serial',required=True)
args=parser.parse_args()
adb=['adb','-s',args.serial]
def shell(command):return subprocess.check_output(adb+['shell',command],text=True,timeout=180).strip()
if shell('getprop ro.product.codename')!='beatles':raise ValueError('Unexpected device')
if shell('getprop init.svc.b2g')!='running':raise ValueError('Stop Vulpes before synchronizing Gaia')
if shell('test -d '+REMOTE+'/opt/vulpes/assets && echo ready')!='ready':raise ValueError('Stage the Nokia runtime first')
spec=importlib.util.spec_from_file_location('package_gaia',ROOT/'tundra/tools/package-beatles-gaia.py')
package=importlib.util.module_from_spec(spec);spec.loader.exec_module(package)
files=package.shared_files()
current={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in files}
raw=shell('cat '+REMOTE+'/work/gaia-sync.json 2>/dev/null || true')
previous=json.loads(raw).get('files',{}) if raw else {}
changed,removed=changes(current,previous)
out=ROOT/'tundra/out/beatles/gecko-156.0.1'
manifest=out/'gaia-sync.json'
manifest.write_text(json.dumps({'files':current},indent=2)+'\n')
if changed or removed:
archive=out/'gaia-sync.tar.gz'
with tarfile.open(archive,'w:gz',compresslevel=1,dereference=True) as tar:
for name in changed:tar.add(ROOT/name,arcname='opt/vulpes/'+name)
digest=hashlib.sha256(archive.read_bytes()).hexdigest()
remote=REMOTE+'/work/gaia-sync.tar.gz'
subprocess.run(adb+['push',str(archive),remote],check=True,capture_output=True,timeout=300)
if shell('/system/bin/busybox sha256sum '+remote).split()[0]!=digest:raise ValueError('Gaia transfer checksum mismatch')
shell('/system/bin/busybox tar -xzf '+remote+' -C '+REMOTE+' && rm '+remote)
# Only files recorded by this synchronizer may be removed; profiles stay outside its inventory.
for start in range(0,len(removed),100):
shell('rm -f '+' '.join(shlex.quote(REMOTE+'/opt/vulpes/'+p) for p in removed[start:start+100]))
subprocess.run(adb+['push',str(manifest),REMOTE+'/work/gaia-sync.json.new'],check=True,capture_output=True)
shell('mv '+REMOTE+'/work/gaia-sync.json.new '+REMOTE+'/work/gaia-sync.json')
print(json.dumps({'files':len(current),'updated':len(changed),'removed':len(removed),'profilePreserved':True}))
if __name__=='__main__':main()

View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Cycle Gaia airplane mode and verify Nokia modem state, then restore settings.
Requires an idle manual Nokia trial. Does not call, send messages or flash.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import time
spec=importlib.util.spec_from_file_location('wifi',Path(__file__).with_name('test-beatles-wifi.py'))
bridge=importlib.util.module_from_spec(spec);spec.loader.exec_module(bridge)
probe=lambda code:bridge.probe('system',code)
def state():
return probe('''const s=await navigator.mozSettings.createLock().get('*');
const h=await VulpesCompat.call('platform.snapshot',{});
return {airplane:s['airplaneMode.enabled'],status:s['airplaneMode.status'],wifi:s['wifi.enabled'],nativeWifi:h.wifi.enabled,wifiState:h.wifi.state,
radios:h.radio.map(r=>({slot:r.slot,state:r.radioState,registered:!!r.voice?.connected,type:r.voice?.type})),
gaia:[...navigator.mozMobileConnections].map(c=>c.radioState)};''')
def wait(predicate,seconds=45):
deadline=time.monotonic()+seconds
while time.monotonic()<deadline:
value=state()
if predicate(value):return value
time.sleep(1)
raise AssertionError(value)
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args()
if bridge.controls.shell('getprop ro.product.codename')!='beatles' or bridge.controls.shell('getprop init.svc.b2g')!='stopped':
raise RuntimeError('Requires an active Nokia phone trial')
if probe('return (await VulpesCompat.call("platform.calls",{})).length;'):
raise RuntimeError('An active call prevents qualification')
initial=state()
if initial['airplane'] or not all(r['state']=='enabled' for r in initial['radios']):
raise RuntimeError('Begin with both radios enabled and airplane mode off')
report={'initial':initial}
try:
probe('await navigator.mozSettings.createLock().set({"airplaneMode.enabled":true});return true;')
report['disabled']=wait(lambda s:s['status']=='enabled' and s['nativeWifi'] is False and all(r['state']=='disabled' for r in s['radios']) and all(r=='disabled' for r in s['gaia']))
probe('await navigator.mozSettings.createLock().set({"airplaneMode.enabled":false});return true;')
report['enabled']=wait(lambda s:s['status']=='disabled' and s['nativeWifi']==initial['nativeWifi'] and all(r['state']=='enabled' for r in s['radios']) and all(r=='enabled' for r in s['gaia']))
if initial['radios'][0]['registered']:
report['networkReturn']=wait(lambda s:s['radios'][0]['registered'],90)
print('Gaia airplane mode, modem power readback and network return: OK')
finally:
try:
probe('await navigator.mozSettings.createLock().set({"airplaneMode.enabled":false});return true;')
# Also recover the modem if Gaia's transition was interrupted.
for radio in initial['radios']:
probe('return await VulpesCompat.call("platform.radioPower",'+json.dumps({'slot':radio['slot'],'enabled':True})+');')
probe('await navigator.mozSettings.createLock().set({"wifi.enabled":'+json.dumps(initial['wifi'])+'});return true;')
report['restored']=wait(lambda s:s['nativeWifi']==initial['nativeWifi'] and all(r['state']=='enabled' for r in s['radios']) and all(r=='enabled' for r in s['gaia']))
finally:
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2)+'\n')
if __name__=='__main__':main()

View file

@ -0,0 +1,73 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Check core Gaia panels at Nokia size without calling, sending or saving data.
These are programmatic UI checks, not a physical keypad qualification.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import time
spec=importlib.util.spec_from_file_location('wifi',Path(__file__).with_name('test-beatles-wifi.py'))
bridge=importlib.util.module_from_spec(spec);spec.loader.exec_module(bridge)
probe=bridge.probe
def wait(app,script,seconds=40):
deadline=time.monotonic()+seconds
while time.monotonic()<deadline:
try:value=probe(app,script)
except RuntimeError as error:
if 'find(...) is null' not in str(error):raise
value=None
if value:return value
time.sleep(.3)
raise AssertionError((app,script))
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args()
if bridge.controls.shell('getprop ro.product.codename')!='beatles' or bridge.controls.shell('getprop init.svc.b2g')!='stopped':
raise RuntimeError('Requires an active Nokia phone trial')
report={'device':'beatles','input':'programmatic DOM; no physical keys','checks':{}}
cases=[('contacts','communications','contacts','#add-contact-button','/contacts/'),
('dialer','communications','dialer','#keypad-callbar-call-action','/dialer/'),
('sms','sms',None,'#threads-composer-link','/'),
('settings','settings',None,'#menuItem-wifi','/')]
try:
for label,app,entry,selector,path in cases:
params={'manifestURL':'http://'+app+'.localhost:8765/manifest.webapp'}
if entry:params['entryPoint']=entry
probe('system','await VulpesCompat.call("apps.launch",'+json.dumps(params)+');return true;')
wait(app,'return location.pathname.startsWith('+json.dumps(path)+') && document.readyState === "complete" && !!document.querySelector('+json.dumps(selector)+');')
time.sleep(1)
report['checks'][label]=probe(app,'''const e=document.querySelector(SELECTOR),r=e.getBoundingClientRect();return {
ready:true,width:innerWidth,height:innerHeight,documentWidth:document.documentElement.scrollWidth,
overflowX:getComputedStyle(document.documentElement).overflowX,scrollX,
control:{x:r.x,y:r.y,width:r.width,height:r.height,visible:e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})}};'''.replace('SELECTOR',json.dumps(selector)))
if label=='contacts':
probe(app,'document.querySelector("#add-contact-button").click();return true;')
wait(app,'return document.querySelector("#givenName")?.getBoundingClientRect().height>0;')
report['checks'][label]['newContactForm']=True
if label=='sms':
probe(app,'document.querySelector("#threads-composer-link").click();return true;')
wait(app,'return document.querySelector("#messages-recipients-list [contenteditable]")?.getBoundingClientRect().height>0;')
report['checks'][label]['recipientComposer']=True
# Close only the app just launched, discarding empty unsaved forms.
probe('system','const a=Service.query("getActiveApp");if(a?.manifestURL!=='+json.dumps(params['manifestURL'])+')throw Error("Unexpected active app");a.kill();return true;')
time.sleep(.5)
for value in report['checks'].values():
assert value['width']==240 and value['scrollX']==0,value
# Gaia Settings keeps inactive panels offscreen behind an overflow clip.
assert value['documentWidth']<=242 or value['overflowX'] in ('hidden','clip'),value
control=value['control']
assert control['width']>0 and control['visible'],value
assert control['x']>=-1 and control['x']+control['width']<=241,value
print(json.dumps(report,indent=2))
finally:
probe('system','dispatchEvent(new CustomEvent("home"));return true;')
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2)+'\n')
if __name__=='__main__':main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Inspect Nokia Fastboot, optionally boot a verified image in RAM. Never flashes."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import time
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
parser.add_argument('--boot',action='store_true')
args=parser.parse_args();folder=args.build.resolve()
build=json.loads((folder/'build.json').read_text());image=folder/build['image']
if build['device']!='beatles' or hashlib.sha256(image.read_bytes()).hexdigest()!=build['sha256']:
raise ValueError('Unexpected build/image')
rows=subprocess.check_output(['fastboot','devices'],text=True,timeout=10).splitlines()
devices=[r.split()[0] for r in rows if len(r.split())==2 and r.split()[1]=='fastboot']
if len(devices)!=1:raise RuntimeError('Exactly one device in Fastboot is required; no reboot, unlock or write attempted')
base=['fastboot','-s',devices[0]]
report={'timestamp':time.time(),'bootSent':False,'flashed':False}
def query(*command):
r=subprocess.run(base+list(command),capture_output=True,text=True,timeout=15)
value=r.stdout+r.stderr;report[' '.join(command)]={'code':r.returncode,'output':value};return r.returncode,value
_,product=query('getvar','product');_,info=query('oem','device-info')
output=folder/'bootloader-inspection.json';output.write_text(json.dumps(report,indent=2)+'\n')
if not args.boot:print('Inspection saved:',output);return
if not re.search(r'(?im)\bproduct:\s*(?:beatles|Nokia_2720_Flip)\s*$',product):
raise RuntimeError('Bootloader product not positively identified as Nokia beatles')
if not re.search(r'(?im)\bDevice unlocked:\s*true\s*$',info):
raise RuntimeError('Unlocked bootloader not confirmed; automatic unlock is deliberately not supported')
result=subprocess.run(base+['boot',str(image)],capture_output=True,text=True,timeout=90)
report['bootSent']=result.returncode==0;report['bootResult']={'code':result.returncode,'output':result.stdout+result.stderr}
output.write_text(json.dumps(report,indent=2)+'\n')
if result.returncode:raise RuntimeError('RAM boot rejected; inspect '+str(output))
print('Image accepted for RAM boot. Gaia startup still needs validation; no partition written.')
if __name__=='__main__':main()

View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Check browser address entry with the Nokia keypad in a manual Gaia session.
Uses a local HTTP response and a reserved .invalid hostname, without relying
on Internet access. Does not send messages, call, reboot or flash the phone.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import time
spec = importlib.util.spec_from_file_location('controls', Path(__file__).with_name('test-beatles-controls.py'))
controls = importlib.util.module_from_spec(spec)
spec.loader.exec_module(controls)
probe, inject = controls.probe, controls.inject
def until(script, timeout=15):
deadline = time.monotonic()+timeout
while time.monotonic() < deadline:
result = probe(script)
if result:
return result
time.sleep(.2)
raise TimeoutError(script)
def address():
# Up scrolls a long page first; once at the top it returns to the toolbar.
for _ in range(8):
if probe('return document.activeElement.classList.contains("urlbar");'):
break
inject('ArrowUp')
time.sleep(.35)
until('return document.activeElement.classList.contains("urlbar");')
inject('Enter')
until('return document.activeElement.id === "rocketbar-input";')
def navigate(url):
probe('const e=document.querySelector("#rocketbar-input");e.value='+json.dumps(url)+';e.dispatchEvent(new Event("input",{bubbles:true}));return true;')
inject('Enter')
until('return !document.querySelector("#rocketbar").classList.contains("active") && [...document.querySelectorAll("iframe")].some(f=>f.dataset.url==='+json.dumps(url)+');')
time.sleep(3)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
report = {'device':'beatles','internetAccessRequired':False}
try:
probe('await VulpesCompat.call("apps.launch",{manifestURL:"http://search.localhost:8765/manifest.webapp"});return true;')
until('return [...document.querySelectorAll(".urlbar")].some(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) && e.getBoundingClientRect().width>0);')
address()
report['addressFocusFromWelcome'] = True
probe('const e=document.querySelector("#rocketbar-input");e.value="";e.dispatchEvent(new Event("input",{bubbles:true}));return true;')
inject('2')
until('return document.querySelector("#rocketbar-input").value === "a";')
report['multitapInput'] = True
inject('Escape')
until('return !document.querySelector("#rocketbar").classList.contains("active");')
report['cancelAddress'] = True
address()
# The Gaia server returns an HTTP 421 document for an unregistered host.
# This exercises the top-level web view without a network dependency.
navigate('http://127.0.0.1:8765/')
address()
report['addressFocusFromWebView'] = True
inject('Escape')
until('return !document.querySelector("#rocketbar").classList.contains("active");')
inject('ArrowUp')
until('return document.activeElement.classList.contains("urlbar");')
inject('ArrowDown')
until('return document.activeElement.localName === "iframe";')
report['toolbarToPage'] = True
address()
navigate('https://vulpes-keypad-test.invalid/')
address()
report['addressFocusFromNetworkError'] = True
# Leave the real welcome page open for the user.
navigate('http://search.localhost:8765/home/index.html')
address()
probe('const e=document.querySelector("#rocketbar-input");e.select();return true;')
report['readyForManualAddressEntry'] = True
finally:
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,104 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Qualify Nokia controls in an already running manual Gaia diagnostic session.
Injects one volume-up/down pair, dims briefly and vibrates for 500 ms.
Restores Gaia settings afterwards. Does not call, send SMS, reboot or flash.
"""
import argparse,json,subprocess,tempfile,time,uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
BASE='/data/local/vulpes-beatles/rootfs/work/'
def shell(command):
return subprocess.check_output(['adb','shell',command],text=True,timeout=10).strip()
def publish(name,value):
with tempfile.NamedTemporaryFile(mode='w') as f:
json.dump(value,f);f.flush()
subprocess.run(['adb','push',f.name,BASE+name+'.tmp'],capture_output=True,check=True)
shell('mv '+BASE+name+'.tmp '+BASE+name)
def probe(script):
identifier=uuid.uuid4().hex
publish('manual-request.json',{'id':identifier,'app':'system','script':script})
deadline=time.monotonic()+20
while time.monotonic()<deadline:
try:response=json.loads(shell('cat '+BASE+'manual-response.json'))
except json.JSONDecodeError:response={}
if response.get('id')==identifier:
if 'error' in response:raise RuntimeError(response['error'])
return response['value']
time.sleep(.1)
raise TimeoutError('Manual diagnostic did not answer')
def inject(key):
try:sequence=json.loads(shell('cat '+BASE+'keypad-response.json')).get('sequence',0)+1
except json.JSONDecodeError:sequence=1
if sequence>64:raise RuntimeError('Start a fresh manual session')
publish('keypad-request.json',{'sequence':sequence,'key':key})
deadline=time.monotonic()+5
while time.monotonic()<deadline:
try:reply=json.loads(shell('cat '+BASE+'keypad-response.json'))
except json.JSONDecodeError:reply={}
if reply.get('sequence')==sequence:return
time.sleep(.1)
raise TimeoutError('Physical input bridge did not acknowledge')
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args()
if shell('getprop ro.product.codename')!='beatles' or shell('getprop init.svc.b2g')!='stopped':
raise RuntimeError('Requires the Nokia manual Gaia trial')
# Gaia adjusts media volume inside an app, but notification volume on Home.
probe('dispatchEvent(new CustomEvent("home"));return true;')
time.sleep(1)
report={};initial=int(shell('cat /sys/class/leds/lcd-backlight/brightness'))
volume=probe('return (await navigator.mozSettings.createLock().get("audio.volume.notification"))["audio.volume.notification"];')
try:
probe('window.__controlCleanup?.(); window.__controlProof={events:[],hud:[]}; const buttons=e=>__controlProof.events.push(e.detail.type); const hud=()=>setTimeout(()=>__controlProof.hud.push(getComputedStyle(document.getElementById("volume")).display),100); addEventListener("softwareButtonEvent",buttons); for(const event of ["volumeup","volumedown"])addEventListener(event,hud); window.__controlCleanup=()=>{removeEventListener("softwareButtonEvent",buttons); for(const event of ["volumeup","volumedown"])removeEventListener(event,hud);}; return true;')
# Establish a middle volume so both buttons can change it.
probe('return await navigator.mozSettings.createLock().set({"audio.volume.notification":7});')
def settle(expected):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
gaia=probe('return (await navigator.mozSettings.createLock().get("audio.volume.notification"))["audio.volume.notification"];')
native=shell('service call media.audio_policy 17 i32 5 i32 2')
if gaia==expected and ('%08x 00000000'%expected) in native:return gaia,native
time.sleep(.2)
raise AssertionError({'expected':expected,'gaia':gaia,'native':native})
settle(7)
inject('AudioVolumeUp')
report['volumeUp'],report['nativeUp']=settle(8)
inject('AudioVolumeDown')
report['volumeDown'],report['nativeDown']=settle(7)
report['ui']=probe('return window.__controlProof;')
probe('return await VulpesCompat.call("desktop.screen",{name:"screenBrightness",value:.2});')
report['backlight20percent']=int(shell('cat /sys/class/leds/lcd-backlight/brightness'))
with ThreadPoolExecutor(max_workers=1) as pool:
future=pool.submit(probe,'return await VulpesCompat.call("hardware.vibrate",{pattern:[500]});')
samples=[];deadline=time.monotonic()+15;completed_at=None
while time.monotonic()<deadline:
samples.append(int(shell('cat /sys/class/timed_output/vibrator/enable')));time.sleep(.05)
if future.done():
if completed_at is None:completed_at=time.monotonic()
if time.monotonic()-completed_at>1:break
future.result()
report['vibration']={'positiveSample':any(n>0 for n in samples),'final':samples[-1]}
finally:
probe('return await VulpesCompat.call("hardware.vibrate",{pattern:[]});')
probe('return await VulpesCompat.call("desktop.screen",{name:"screenBrightness",value:'+str(initial/255)+'});')
probe('return await navigator.mozSettings.createLock().set({"audio.volume.notification":'+str(volume)+'});')
probe('window.__controlCleanup?.();delete window.__controlCleanup;delete window.__controlProof;return true;')
report['restoredBacklight']=int(shell('cat /sys/class/leds/lcd-backlight/brightness'))
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2)+'\n')
assert report['volumeUp']==8 and report['volumeDown']==7,report
assert '00000008 00000000' in report['nativeUp'] and '00000007 00000000' in report['nativeDown'],report
assert 'block' in report['ui']['hud'],report
assert report['backlight20percent']==51 and report['restoredBacklight']==initial,report
assert report['vibration']['positiveSample'] and report['vibration']['final']==0,report
print('Gaia volume HUD, native audio indexes, backlight and bounded vibration: OK')
if __name__=='__main__':main()

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Repeatable Gaia frame-callback/scroll and system-load measurements on Nokia.
Frame callbacks measure application scheduling, not physical panel refresh.
No calls, messages, connectivity changes or flash operations.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import time
spec=importlib.util.spec_from_file_location('wifi_probe',Path(__file__).with_name('test-beatles-wifi.py'))
bridge=importlib.util.module_from_spec(spec);spec.loader.exec_module(bridge)
probe=bridge.probe
def counters():
rows=bridge.controls.shell('cat /proc/stat /proc/vmstat /proc/meminfo /proc/uptime').splitlines()
result={}
for row in rows:
fields=row.split()
if fields[0]=='cpu':result['cpu']=list(map(int,fields[1:9]))
elif fields[0] in ('processes','pgmajfault','pswpin','pswpout','MemFree:','Cached:','SwapFree:'):
result[fields[0].rstrip(':')]=int(fields[1])
result['uptime']=float(rows[-1].split()[0])
return result
def delta(a,b):
cpu=[y-x for x,y in zip(a['cpu'],b['cpu'])];duration=b['uptime']-a['uptime']
return {'seconds':duration,'cpuBusyPercent':round(100*(1-cpu[3]/sum(cpu)),2),
'taskCreationsPerSecond':round((b['processes']-a['processes'])/duration,2),
'majorFaultsPerSecond':round((b['pgmajfault']-a['pgmajfault'])/duration,2),
'swapInPagesPerSecond':round((b['pswpin']-a['pswpin'])/duration,2),
'swapFreeKiB':b['SwapFree']}
def measure(app,scroll=False):
# Find a real scroll container; preserve its position after the benchmark.
script='''const scroll=SCROLL;
const candidates=[...document.querySelectorAll('body *')].filter(e=>e.clientHeight>100 && e.scrollHeight>e.clientHeight+40 && ['auto','scroll'].includes(getComputedStyle(e).overflowY) && e.checkVisibility());
const surface=candidates.sort((a,b)=>b.clientHeight-a.clientHeight)[0];
if(scroll && !surface)throw Error('No visible scroll surface');
const old=surface?.scrollTop || 0,available=surface?surface.scrollHeight-surface.clientHeight:0;
const max=Math.min(available,500);
return await new Promise(resolve=>{const gaps=[];let previous=performance.now(),start=previous;
function frame(now){gaps.push(now-previous);previous=now;
if(scroll)surface.scrollTop=max*(1-Math.cos((now-start)*Math.PI/1500))/2;
if(now-start<6000)requestAnimationFrame(frame);
else{if(scroll)surface.scrollTop=old;const sorted=gaps.slice().sort((a,b)=>a-b);
resolve({frames:gaps.length,seconds:(now-start)/1000,callbackFps:gaps.length*1000/(now-start),
p50ms:sorted[Math.floor(sorted.length*.5)],p95ms:sorted[Math.floor(sorted.length*.95)],
stallsOver50ms:gaps.filter(n=>n>50).length,scrollRange:scroll?max:0,
surface:scroll?(surface.id || surface.tagName):null,availableScrollRange:available});}}
requestAnimationFrame(frame);
});'''.replace('SCROLL',json.dumps(scroll))
a=counters();value=probe(app,script);b=counters()
return {**value,'system':delta(a,b)}
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args();report={'device':'beatles','metric':'requestAnimationFrame callbacks, not panel FPS',
'cpuMetric':'Non-idle CPU time, including I/O wait','measurements':{}}
try:
probe('system','dispatchEvent(new CustomEvent("home"));return true;');time.sleep(2)
report['measurements']['homeIdle']=measure('homescreen')
report['measurements']['homeScroll']=measure('homescreen',True)
probe('system','await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
deadline=time.monotonic()+45
while True:
try:
ready=probe('settings','return document.readyState === "complete" && typeof require === "function" && !!document.querySelector("#root");')
except RuntimeError as error:
if 'find(...) is null' not in str(error):raise
ready=False
if ready:break
if time.monotonic()>deadline:raise RuntimeError('Settings did not finish loading')
time.sleep(1)
probe('settings','document.querySelector("#wifi-status button[type=reset]")?.click();require(["modules/settings_service"],s=>s.navigate("root"));return true;')
time.sleep(3)
report['measurements']['settingsScroll']=measure('settings',True)
finally:
args.output.parent.mkdir(parents=True,exist_ok=True);args.output.write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Check Settings keypad navigation and Gaia switch activation on a Nokia trial.
Temporarily toggles geolocation and restores its setting. Does not toggle
flight mode, send messages, make calls or flash the phone.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import time
spec=importlib.util.spec_from_file_location('wifi_probe',Path(__file__).with_name('test-beatles-wifi.py'))
bridge=importlib.util.module_from_spec(spec);spec.loader.exec_module(bridge)
probe=bridge.probe
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args()
if bridge.controls.shell('getprop ro.product.codename')!='beatles' or bridge.controls.shell('getprop init.svc.b2g')!='stopped':
raise RuntimeError('Requires an active Nokia Vulpes phone trial')
probe('system','await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
deadline=time.monotonic()+45
while True:
try:ready=probe('settings','return document.readyState === "complete" && typeof require === "function";')
except RuntimeError as error:
if 'find(...) is null' not in str(error):raise
ready=False
if ready:break
if time.monotonic()>deadline:raise TimeoutError('Settings startup')
time.sleep(1)
probe('settings','require(["modules/settings_service"],s=>s.navigate("root"));return true;')
time.sleep(2)
initial=probe('settings','return {geo:document.querySelector("gaia-switch[name=\\"geolocation.enabled\\"]").checked,plane:document.querySelector(".airplaneMode-input").checked};')
report={'device':'beatles','path':[]}
focus='''const e=document.activeElement;const r=e.getBoundingClientRect();return {
id:e.querySelector('label')?.id || e.id,top:r.top,bottom:r.bottom,
visible:r.top>=0 && r.bottom<=innerHeight};'''
def move(key,expected):
bridge.controls.inject(key)
result=bridge.until('const id=document.activeElement.querySelector("label")?.id || document.activeElement.id;return id === '+json.dumps(expected)+';',app='settings',timeout=6)
state=probe('settings',focus)
if not state['visible']:raise AssertionError(state)
report['path'].append({'key':key,**state})
def geo(expected):
value=json.dumps(expected)
return bridge.until('return document.querySelector("gaia-switch[name=\\"geolocation.enabled\\"]").checked === '+value+' && (await navigator.mozSettings.createLock().get("geolocation.enabled"))["geolocation.enabled"] === '+value+';',app='settings',timeout=10)
try:
probe('settings','document.getElementById("menuItem-wifi").focus();return true;')
move('ArrowUp','menuItem-gps')
move('ArrowUp','menuItem-airplaneMode')
move('ArrowDown','menuItem-gps')
bridge.controls.inject('Enter');geo(not initial['geo'])
bridge.controls.inject('Enter');geo(initial['geo'])
report['okTogglesAndRestoresGeolocation']=True
move('ArrowDown','menuItem-wifi')
report['airplaneUnchanged']=probe('settings','return document.querySelector(".airplaneMode-input").checked === '+json.dumps(initial['plane'])+';')
assert report['airplaneUnchanged']
finally:
probe('settings','await navigator.mozSettings.createLock().set({"geolocation.enabled":'+json.dumps(initial['geo'])+'});return true;')
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Qualify Nokia Wi-Fi in an active manual trial using its saved network.
Briefly disconnects Wi-Fi, restores it in finally, and checks HTTPS access.
Does not read passwords, delete saved networks, call, send SMS or flash.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import time
import uuid
spec=importlib.util.spec_from_file_location('controls',Path(__file__).with_name('test-beatles-controls.py'))
controls=importlib.util.module_from_spec(spec);spec.loader.exec_module(controls)
def probe(app,script):
ident=uuid.uuid4().hex
controls.publish('manual-request.json',{'id':ident,'app':app,'script':script})
deadline=time.monotonic()+38
while time.monotonic()<deadline:
try:reply=json.loads(controls.shell('cat '+controls.BASE+'manual-response.json'))
except json.JSONDecodeError:reply={}
if reply.get('id')==ident:
if 'error' in reply:raise RuntimeError(reply['error'])
return reply['value']
time.sleep(.2)
raise TimeoutError('Gaia diagnostic did not answer')
def until(script,app='system',timeout=40):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
value=probe(app,script)
if value:return value
time.sleep(1)
raise TimeoutError('Wi-Fi condition not reached: '+script)
def power(enabled):
probe('system','await navigator.mozSettings.createLock().set({"wifi.enabled":'+json.dumps(enabled)+'});return true;')
until('return (await VulpesCompat.call("platform.wifi",{operation:"status"})).enabled === '+json.dumps(enabled)+';')
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',type=Path,required=True)
args=parser.parse_args()
if controls.shell('getprop ro.product.codename')!='beatles' or controls.shell('getprop init.svc.b2g')!='stopped':
raise RuntimeError('Requires an active Nokia Vulpes phone trial')
initial=probe('system','return await VulpesCompat.call("platform.wifi",{operation:"status"});')
if not initial.get('enabled') or initial.get('state')!='connected':
raise RuntimeError('Connect a saved Wi-Fi network before qualification')
report={'device':'beatles','date':time.strftime('%Y-%m-%d'),'initiallyConnected':True}
try:
report['networksFound']=probe('system','return (await VulpesCompat.call("platform.wifi",{operation:"scan"})).networks.length;')
power(False)
flags=int(controls.shell('cat /sys/class/net/wlan0/flags'),16)
if flags&1:raise AssertionError('wlan0 remains up')
report['gaiaSwitchDisablesInterface']=True
power(True)
until('return (await VulpesCompat.call("platform.wifi",{operation:"status"})).state === "connected";')
report['savedNetworkReconnects']=True
until('return navigator.mozWifiManager.connection.status === "connected";')
report['gaiaFollowsReconnect']=True
report['associateSavedNetwork']=probe('system','const m=navigator.mozWifiManager;const rows=await m.getNetworks();const n=rows.find(n=>n.connected && n.known);if(!n)throw Error("No saved association");await m.associate(n);return true;')
until('return !!(await VulpesCompat.call("platform.wifi",{operation:"status"})).connectionInformation?.ipAddress;')
report['httpsAfterReconnect']=probe('system','const r=await fetch("https://example.com/?vulpes-wifi-test="+Date.now(),{mode:"no-cors",cache:"no-store",signal:AbortSignal.timeout(15000)});return r.type==="opaque";')
finally:
power(True)
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,61 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Compare local backup hashes with read-only hashes computed on the Nokia."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
from kaios_debug import KaiOSDebugger
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('folder', type=Path)
args = parser.parse_args()
report = json.loads((args.folder / 'backup.json').read_text())
lines = subprocess.check_output(['adb', 'devices', '-l'], text=True).splitlines()
serials = [line.split()[0] for line in lines if len(line.split()) > 2
and line.split()[1] == 'device' and 'model:Nokia_2720_Flip' in line.split()]
if len(serials) != 1:
raise RuntimeError('Exactly one connected Nokia 2720 Flip is required')
codename = subprocess.check_output(['adb', '-s', serials[0], 'shell',
'getprop ro.product.codename'], text=True).strip()
if codename != 'beatles' or report['device'] != codename:
raise RuntimeError('Device mismatch')
results = {}
with KaiOSDebugger(serials[0]) as client:
console = client.request('root', 'getProcess', id=0)['form']['consoleActor']
for name in ('boot', 'recovery', 'aboot', 'abootbak'):
entry = report['partitions'][name]
path = args.folder / (name + '.img')
if path.stat().st_size != entry['bytes']:
raise RuntimeError('Backup size mismatch: ' + name)
with path.open('rb') as stream:
local = hashlib.file_digest(stream, 'sha256').hexdigest()
expression = '''(function(){
var Cc=Components.classes,Ci=Components.interfaces;
var f=Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
f.initWithPath(%s);
var s=Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
s.init(f,1,0,0);
try {
var h=Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
h.init(h.SHA256);h.updateFromStream(s,%d);
var raw=h.finish(false),hex="";
for(var i=0;i<raw.length;i++)hex+=("0"+raw.charCodeAt(i).toString(16)).slice(-2);
return hex;
} finally {s.close();}
})()''' % (json.dumps('/dev/block/bootdevice/by-name/' + name), entry['bytes'])
remote = client.evaluate(console, expression)
if not (remote == local == entry['sha256']):
raise RuntimeError('Partition hash mismatch: ' + name)
results[name] = {'bytes': entry['bytes'], 'sha256': local, 'matchesDevice': True}
print(name + ': local and device SHA256 match', flush=True)
(args.folder / 'verification.json').write_text(json.dumps({
'device': codename, 'method': 'device nsICryptoHash, read-only block stream',
'restorationTested': False, 'partitions': results}, indent=2) + '\n')
if __name__ == '__main__':
main()