Release Vulpes OS 2.7-preview.22
This commit is contained in:
parent
200d1fb235
commit
a138f97877
294 changed files with 515786 additions and 187 deletions
69
tundra/tools/backup-beatles-boot.py
Normal file
69
tundra/tools/backup-beatles-boot.py
Normal 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.')
|
||||
81
tundra/tools/beatles_boot.py
Normal file
81
tundra/tools/beatles_boot.py
Normal 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}
|
||||
37
tundra/tools/build-armv7-probe.py
Normal file
37
tundra/tools/build-armv7-probe.py
Normal 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'])
|
||||
74
tundra/tools/build-beatles-boot.py
Normal file
74
tundra/tools/build-beatles-boot.py
Normal 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()
|
||||
57
tundra/tools/build-beatles-gecko.sh
Normal file
57
tundra/tools/build-beatles-gecko.sh
Normal 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}"
|
||||
37
tundra/tools/build-beatles-hal-inventory.py
Normal file
37
tundra/tools/build-beatles-hal-inventory.py
Normal 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()
|
||||
63
tundra/tools/build-beatles-t9.py
Normal file
63
tundra/tools/build-beatles-t9.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
76
tundra/tools/install-beatles-diagnostic.py
Normal file
76
tundra/tools/install-beatles-diagnostic.py
Normal 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')
|
||||
81
tundra/tools/kaios_debug.py
Normal file
81
tundra/tools/kaios_debug.py
Normal 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
|
||||
87
tundra/tools/package-beatles-build.py
Normal file
87
tundra/tools/package-beatles-build.py
Normal 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()
|
||||
45
tundra/tools/package-beatles-gaia.py
Normal file
45
tundra/tools/package-beatles-gaia.py
Normal 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()
|
||||
49
tundra/tools/package-beatles-runtime.py
Normal file
49
tundra/tools/package-beatles-runtime.py
Normal 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()
|
||||
40
tundra/tools/prepare-beatles-cover-animation.py
Normal file
40
tundra/tools/prepare-beatles-cover-animation.py
Normal 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))
|
||||
166
tundra/tools/prepare-beatles-gecko.py
Normal file
166
tundra/tools/prepare-beatles-gecko.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
72
tundra/tools/run-beatles-display.py
Normal file
72
tundra/tools/run-beatles-display.py
Normal 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()
|
||||
198
tundra/tools/run-beatles-gaia.py
Normal file
198
tundra/tools/run-beatles-gaia.py
Normal 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()
|
||||
75
tundra/tools/run-beatles-gecko-probe.py
Normal file
75
tundra/tools/run-beatles-gecko-probe.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
81
tundra/tools/stage-beatles-build.py
Normal file
81
tundra/tools/stage-beatles-build.py
Normal 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()
|
||||
70
tundra/tools/stage-beatles-gecko.py
Normal file
70
tundra/tools/stage-beatles-gecko.py
Normal 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()
|
||||
62
tundra/tools/sync-beatles-gaia.py
Normal file
62
tundra/tools/sync-beatles-gaia.py
Normal 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()
|
||||
63
tundra/tools/test-beatles-airplane.py
Normal file
63
tundra/tools/test-beatles-airplane.py
Normal 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()
|
||||
73
tundra/tools/test-beatles-apps.py
Normal file
73
tundra/tools/test-beatles-apps.py
Normal 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()
|
||||
43
tundra/tools/test-beatles-boot.py
Normal file
43
tundra/tools/test-beatles-boot.py
Normal 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()
|
||||
95
tundra/tools/test-beatles-browser.py
Normal file
95
tundra/tools/test-beatles-browser.py
Normal 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()
|
||||
104
tundra/tools/test-beatles-controls.py
Normal file
104
tundra/tools/test-beatles-controls.py
Normal 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()
|
||||
90
tundra/tools/test-beatles-fluidity.py
Normal file
90
tundra/tools/test-beatles-fluidity.py
Normal 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()
|
||||
69
tundra/tools/test-beatles-settings-keypad.py
Normal file
69
tundra/tools/test-beatles-settings-keypad.py
Normal 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()
|
||||
78
tundra/tools/test-beatles-wifi.py
Normal file
78
tundra/tools/test-beatles-wifi.py
Normal 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()
|
||||
61
tundra/tools/verify-beatles-backup.py
Normal file
61
tundra/tools/verify-beatles-backup.py
Normal 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()
|
||||
Loading…
Reference in a new issue