Release Vulpes OS 2.7-preview.24

This commit is contained in:
OverSu 2026-11-04 12:52:20 +01:00
commit 97ef99f827
135 changed files with 7717 additions and 2524 deletions

View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Low-overhead, read-only battery and thermal sampler for a RAM trial."""
import json
from pathlib import Path
import time
def values(directory, names):
result = {}
for name in names:
try:
result[name] = (directory / name).read_text().strip()
except OSError:
pass
return result
def sample():
supplies = {}
for path in sorted(Path('/sys/class/power_supply').iterdir()):
supplies[path.name] = values(path, (
'type', 'status', 'online', 'capacity', 'current_now',
'voltage_now', 'power_now', 'charge_now', 'energy_now', 'temp'))
thermal = {}
for path in sorted(Path('/sys/class/thermal').glob('thermal_zone*')):
item = values(path, ('type', 'temp'))
if item:
thermal[path.name] = item
idle = {}
for path in sorted(Path('/sys/devices/system/cpu').glob('cpu[0-9]*/cpuidle/state*')):
item = values(path, ('name', 'time', 'usage', 'disable'))
if item:
idle[str(path.relative_to('/sys/devices/system/cpu'))] = item
return {'timeUnix': time.time(), 'timeMonotonic': time.monotonic(),
'powerSupplies': supplies, 'thermalZones': thermal, 'cpuIdle': idle}
def main():
output = Path('/run/tundra/battery-idle.jsonl')
output.parent.mkdir(parents=True, exist_ok=True)
with output.open('a', buffering=1) as stream:
while True:
stream.write(json.dumps(sample(), separators=(',', ':')) + '\n')
time.sleep(30)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build the Nokia CameraService worker and its fixed-command broker."""
import hashlib
import json
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[2]
ENGINE = ROOT / 'tundra/out/beatles/gecko-156.0.1'
BUILD = ENGINE / 'camera-broker-build'
NDK = BUILD / 'android-ndk-r27d'
HEADERS = BUILD / 'android-headers-api23/android-headers-23/23'
AOSP = BUILD / 'aosp/include'
LIBS = ENGINE / 'camera-probe-runtime'
OUT = BUILD
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def build(name, source, command):
target = OUT / name
stamp = target.with_suffix('.build.json')
record = {'source': digest(source), 'command': command}
if target.is_file() and stamp.is_file():
try:
if json.loads(stamp.read_text()) == record:
print('Using cached camera tool:', name)
return target
except (OSError, ValueError):
pass
subprocess.run(command, check=True)
target.chmod(0o755)
stamp.write_text(json.dumps(record, sort_keys=True) + '\n')
return target
def main():
required = [NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang++',
NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang',
HEADERS / 'linux/binder.h', AOSP / 'camera/Camera.h',
LIBS / 'libc++_shared.so', LIBS / 'beatles-camera-permission-bridge']
missing = [str(path) for path in required if not path.exists()]
if missing:
raise SystemExit('Private API-23 Camera1 build inputs missing:\n ' + '\n '.join(missing))
sysroot = NDK / 'toolchains/llvm/prebuilt/linux-x86_64/sysroot'
cxx = str(NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang++')
cc = str(NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang')
common = ['--target=armv7a-linux-androideabi23', '--sysroot=' + str(sysroot)]
worker_source = ROOT / 'tundra/probes/beatles-camera-worker.cpp'
worker_command = [cxx, *common, '-fno-rtti', '-I', str(HEADERS), '-I', str(AOSP),
str(worker_source), '-L', str(LIBS), '-Wl,-rpath,/data/local/tmp',
'-Wl,--allow-shlib-undefined', '-lcamera_client', '-lbinder', '-lgui',
'-lutils', '-lcutils', '-llog', '-lui', '-lhardware', '-lmedia',
'-lcamera_metadata', '-lsync', '-lbase', '-lc++_shared', '-o']
build('beatles-camera-worker', worker_source,
worker_command + [str(OUT / 'beatles-camera-worker')])
broker_source = ROOT / 'tundra/probes/beatles-camera-broker.c'
broker_command = [cc, *common, '-Wall', '-Wextra', '-Werror', str(broker_source), '-o']
build('beatles-camera-broker', broker_source,
broker_command + [str(OUT / 'beatles-camera-broker')])
print('Android Camera1 worker and broker are ready.')
if __name__ == '__main__':
main()

View file

@ -18,6 +18,7 @@ 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 kernel_cmdline import vulpes_cmdline
from tundra import ROOT, cpio, checked_source, digest, save, linker
@ -33,7 +34,9 @@ def main():
parser.add_argument('--layout',type=Path,required=True)
parser.add_argument('--device-workspace', type=Path,
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('--native',action='store_true',help='Switch to the staged native root with a timed watchdog')
parser.add_argument('--watchdog-seconds', type=int, default=600,
help='RAM trial auto-reboot deadline (60–3600 seconds; default: 600)')
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')
@ -45,6 +48,8 @@ def main():
parser.add_argument('--state', type=Path, help='Prepared Tundra data-image manifest; enables persistent writes in that file')
parser.add_argument('--no-deadline', action='store_true', help='Keep the persistent development boot running; requires --state')
args=parser.parse_args()
if not 60 <= args.watchdog_seconds <= 3600:
parser.error('--watchdog-seconds must be between 60 and 3600')
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')
@ -79,6 +84,9 @@ def main():
else:
plan,_=load_boot().checked_plan()
storage_table = mount_table(layout)
if state and storage_table is None and not (
layout.get('schema') == 2 and layout.get('storage') == 'raw-userdata-ext4'):
raise ValueError('Persistent trials require inventoried ext4 userdata')
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg')
@ -86,6 +94,8 @@ def main():
result=subprocess.check_output(['python3',str(packager/'unpack_bootimg.py'),'--boot_img',str(source),'--out',str(unpack),'--format=mkbootimg'],text=True)
options=shlex.split(result);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0':raise ValueError('Expected header v0')
source_cmdline=fields['--cmdline']
fields['--cmdline'],removed_kernel_parameters=vulpes_cmdline(source_cmdline)
with tempfile.TemporaryDirectory() as temp:
helpers=select_helpers(read_newc((unpack/'ramdisk').read_bytes()),Path(temp),
extra=('sbin/blkid','sbin/e2fsck') if state else ())
@ -113,15 +123,23 @@ def main():
(out/'known_hosts').write_text('[10.15.19.82]:2222 '+hostpublic+'\n[127.0.0.1]:2222 '+hostpublic+'\n')
init=(ROOT/'boot/rescue-init.sh').read_bytes().replace(b'# No LVM activation',
('EXPECTED_STORAGE_HASH='+shlex.quote(layout['firstMiBSha256'])+'\n# No LVM activation').encode())
init=init.replace(b"'0 104448000 linear /dev/mmcblk0p72 329728'",
shlex.quote(storage_table).encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py',
if storage_table is None:
uuid_hex=layout['filesystemUuid'].replace('-','')
init=init.replace(b'# No LVM activation',
('RAW_USERDATA_EXT4=1\nEXPECTED_STORAGE_UUID_HEX='+uuid_hex+
'\n# No LVM activation').encode())
else:
init=init.replace(b"'0 104448000 linear /dev/mmcblk0p72 329728'",
shlex.quote(storage_table).encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/kernel_cmdline.py',ROOT/'tools/ramdisk_reference.py',
ROOT/'tools/storage_layout.py']
if args.device_workspace:
sources += [ROOT/'tools/sargo_workspace.py', ROOT/'tools/boot_backup.py']
if state:
# Keep the existing layout intact; only /tundra/data/<UUID>.ext4 is used
# for Tundra state. Opening its containing filesystem RW is necessary.
if storage_table is None:
init=init.replace(b'RAW_USERDATA_EXT4=1',b'RAW_USERDATA_EXT4=1\nTUNDRA_USERDATA_RW=1')
init=init.replace(b'--readonly --table', b'--table')
init=init.replace(b'-o ro,noload /dev/mapper/tundra-installed-ro', b'-o rw /dev/mapper/tundra-installed-ro')
init=init.replace(b'# Temporary RAM-only boot. Installed data is never mounted writable.', b'# Private persistent boot. Only the dedicated Tundra data file receives system writes.')
@ -130,7 +148,7 @@ def main():
shared_ui = {}
if args.native:
binary=out/'watchdog'
subprocess.run(['clang','--target=aarch64-linux-gnu','--ld-path='+str(linker()),'-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/'boot/watchdog.c'),'-o',str(binary)],check=True)
subprocess.run(['clang','--target=aarch64-linux-gnu','--ld-path='+str(linker()),'-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','-DTUNDRA_WATCHDOG_SECONDS='+str(args.watchdog_seconds),str(ROOT/'boot/watchdog.c'),'-o',str(binary)],check=True)
init=init.replace(b"(sleep 480; echo 'Tundra trial deadline reached'; reboot -f) &",b'/sbin/tundra-watchdog &')
if args.no_deadline:
init=init.replace(b'/sbin/tundra-watchdog &', b'# Persistent development boot: no timed reboot.')
@ -174,6 +192,8 @@ def main():
archive.add(config_dir, arcname=directory, filter=owned)
files['native-config.tar']=(0o600,compress_archive(buffer.getvalue()))
sources += list(sorted((ROOT/'runtime').glob('*.py')))
if args.runtime_config:
sources.append(ROOT/'tools/inspect-hardware.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())
if args.runtime_config:
@ -181,7 +201,8 @@ def main():
if system:
sha = system['imageSha256']
native_init = native_init.replace(b'# A kernel fallback',
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+
'\nSYSTEM_IMAGE_BYTES='+str(system['bytes'])+'\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, differential_payload
@ -240,9 +261,10 @@ def main():
shutil.copy2(path,target)
save(out/'build.json',{'scope':'Private persistent Sargo development boot; unqualified' if state else ('Private temporary native-root trial; unqualified' if args.native else 'Private temporary SSH boot; installed root read-only; no graphical session'),
'builtAtUnix':built_at,'runtimeConfigOverlay':args.runtime_config,'networkTrial':args.network,'hardwareTrial':args.hardware,'interactiveDiagnostics':args.diagnostics,'image':image.name,'imageSha256':digest(image),'sourceBootSha256':digest(source),
'removedKernelParameters':removed_kernel_parameters,
'serialSha256':plan['serialSha256'],'kernelSha256':kernel_hash,
'deviceWorkspaceSha256':plan.get('workspaceSha256'),
'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,
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (args.watchdog_seconds 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,

View file

@ -5,6 +5,8 @@ import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import uuid
from persistent_state import FEATURES, digest
@ -15,6 +17,8 @@ def main():
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--system-image', type=Path, required=True)
parser.add_argument('--size-mib', type=int, default=2048)
parser.add_argument('--profile-dir', type=Path,
help='Copy an existing droidian-trial browser profile into a fresh P23 state')
args = parser.parse_args()
if args.size_mib < 512 or args.size_mib > 8192:
parser.error('Choose 512–8192 MiB')
@ -31,6 +35,23 @@ def main():
for directory in ('upper','work'):
(seed/directory).mkdir(mode=0o755 if directory == 'upper' else 0o700)
(seed/'upper').chmod(0o755)
migrated = []
opt_layer = None
if args.profile_dir:
profile = args.profile_dir.resolve(strict=True)
if not profile.is_dir() or profile.is_symlink():
raise ValueError('Profile source must be a real directory')
opt_layer = seed/'upper/opt'
vulpes_layer = opt_layer/'vulpes'
profiles_layer = vulpes_layer/'profiles'
profiles_layer.mkdir(parents=True)
opt_layer.chmod(0o755)
vulpes_layer.chmod(0o755)
profiles_layer.chmod(0o755)
target = profiles_layer/'droidian-trial'
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(profile, target, symlinks=True)
migrated = [vulpes_layer, profiles_layer, target, *target.rglob('*')]
identifier = str(uuid.uuid4())
image = out/(identifier+'.ext4')
with image.open('xb') as stream:
@ -42,12 +63,23 @@ def main():
for field in ('uid','gid'):
subprocess.run(['debugfs','-w','-R',f'set_inode_field {name} {field} 0',str(image)],
check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if migrated:
# Gaia runs as the droidian account (UID/GID 32011) on Sargo.
commands = out/'profile-ownership.debugfs'
commands.write_text(''.join(
f'set_inode_field {shlex.quote("/upper/" + str(path.relative_to(seed/"upper")))} {field} 32011\n'
for path in migrated for field in ('uid','gid')) +
'set_inode_field /upper/opt uid 0\nset_inode_field /upper/opt gid 0\n')
subprocess.run(['debugfs','-w','-f',str(commands),str(image)],check=True,
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
commands.unlink()
check = subprocess.run(['e2fsck','-f','-n',str(image)],text=True,capture_output=True)
(out/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:raise RuntimeError('Data image failed filesystem verification')
manifest = {'schema':1,'device':'sargo','uuid':identifier,'image':image.name,
'imageSha256':digest(image),'bytes':image.stat().st_size,
'systemImageSha256':system['imageSha256'],'filesystemCheckPassed':True,
'migratedProfile':bool(args.profile_dir),
'scope':'Private persistent overlay; containing filesystem retained; no partition formatting'}
(out/'state.json').write_text(json.dumps(manifest,indent=2)+'\n')
print(out/'state.json')

View file

@ -2,6 +2,7 @@
# SPDX-License-Identifier: MPL-2.0
"""Read readiness, never dial, send messages, enter PINs or open camera nodes."""
import json
import os
from pathlib import Path
import subprocess
import time
@ -38,6 +39,150 @@ def selected(values, names):
if key in values and isinstance(values[key], dict)}
def process_cpu_snapshot():
"""Return process names and CPU ticks without command lines or arguments."""
result = {}
for entry in Path('/proc').iterdir():
if not entry.name.isdigit():
continue
try:
raw = (entry / 'stat').read_text()
# comm can contain spaces and parentheses; fields after the last )
# start at field 3 (state), so utime/stime are offsets 11 and 12.
fields = raw[raw.rfind(')') + 2:].split()
name = raw[raw.find('(') + 1:raw.rfind(')')]
result[int(entry.name)] = (name,
int(fields[11]) + int(fields[12]))
except (OSError, ValueError, IndexError):
continue
return result
def thread_cpu_snapshot():
"""Return thread names and CPU ticks; never collect command-line data."""
result = {}
for task in Path('/proc').glob('[0-9]*/task/[0-9]*'):
try:
raw = (task/'stat').read_text()
fields = raw[raw.rfind(')') + 2:].split()
name = raw[raw.find('(') + 1:raw.rfind(')')]
result[(int(task.parent.parent.name), int(task.name))] = (
name, int(fields[11]) + int(fields[12]))
except (OSError, ValueError, IndexError):
continue
return result
def cpu_idle_snapshot():
"""Read per-CPU idle-state residency counters where the kernel exports them."""
result = {}
for cpu in sorted(Path('/sys/devices/system/cpu').glob('cpu[0-9]*')):
if not cpu.name[3:].isdigit():
continue
states = []
for state in sorted((cpu/'cpuidle').glob('state[0-9]*')):
try:
states.append({key:(state/key).read_text().strip() for key in
('name','desc','usage','time','disable')
if (state/key).is_file()})
except OSError:
continue
if states:
result[cpu.name] = states
return result
def cpu_frequency_snapshot():
result = {}
for cpu in sorted(Path('/sys/devices/system/cpu').glob('cpu[0-9]*')):
if not cpu.name[3:].isdigit():
continue
path = cpu/'cpufreq'
values = {}
for key in ('scaling_governor','scaling_cur_freq','cpuinfo_cur_freq',
'scaling_min_freq','scaling_max_freq'):
try:
values[key] = (path/key).read_text().strip()
except OSError:
pass
if values:
result[cpu.name] = values
return result
def thermal_readings():
readings = {}
for zone in Path('/sys/class/thermal').glob('thermal_zone*'):
try:
label = (zone / 'type').read_text().strip()
value = int((zone / 'temp').read_text().strip())
# Linux thermal sysfs normally reports millidegrees Celsius.
readings[label or zone.name] = round(value / 1000, 1)
except (OSError, ValueError):
continue
return readings
def power_supplies():
result = {}
for supply in Path('/sys/class/power_supply').glob('*'):
data = {}
for name in ('type', 'status', 'capacity', 'voltage_now',
'current_now', 'power_now', 'temp', 'online'):
try:
data[name] = (supply / name).read_text().strip()
except OSError:
pass
if data:
result[supply.name] = data
return result
def wakeup_snapshot():
"""Read kernel wakeup counters; source labels are kernel driver names."""
for path in (Path('/sys/kernel/debug/wakeup_sources'),
Path('/sys/kernel/tracing/wakeup_sources')):
try:
lines = path.read_text().splitlines()
except OSError:
continue
if len(lines) < 2:
continue
headers = lines[0].split()
active_index = next((i for i, value in enumerate(headers)
if value.lower() == 'active_count'), None)
event_index = next((i for i, value in enumerate(headers)
if value.lower() == 'event_count'), None)
if active_index is None or event_index is None:
return None
result = {}
for line in lines[1:]:
fields = line.split()
try:
if len(fields) <= max(active_index, event_index):
continue
result[fields[0]] = {
'activeCount':int(fields[active_index]),
'eventCount':int(fields[event_index]),
}
except (ValueError, IndexError):
continue
return result
return None
def counter_delta(before, after, keys):
"""Return positive counter deltas, tolerating counters reset at reboot."""
result = {}
for name, values in (after or {}).items():
prior = (before or {}).get(name, {})
delta = {key:max(0, int(values[key])-int(prior.get(key, values[key])))
for key in keys if key in values}
if any(delta.values()):
result[name] = delta
return result
def inspect():
native = 'tundra.rescue=1' in Path('/proc/cmdline').read_text()
report = {'recordedAtUnix': time.time(), 'environment': 'tundra' if native else 'reference',
@ -80,12 +225,114 @@ def inspect():
'applicationInstalled': Path('/usr/bin/droidian-camera').exists(),
'captureTested': False,
}
report['battery'] = {}
for name in ('capacity', 'status'):
report['battery'] = power_supplies()
report['thermalCelsius'] = thermal_readings()
backlights = {}
for path in Path('/sys/class/backlight').glob('*/brightness'):
try:
report['battery'][name] = Path('/sys/class/power_supply/battery', name).read_text().strip()
backlights[path.parent.name] = (path.read_text().strip(),
(path.parent / 'max_brightness').read_text().strip())
except OSError:
continue
report['backlight'] = backlights
before = process_cpu_snapshot()
threads_before = thread_cpu_snapshot()
idle_before = cpu_idle_snapshot()
wakeups_before = wakeup_snapshot()
battery_before = power_supplies()
started = time.monotonic()
time.sleep(5)
elapsed = time.monotonic() - started
after = process_cpu_snapshot()
threads_after = thread_cpu_snapshot()
ticks_per_second = os.sysconf('SC_CLK_TCK')
cpu = []
for pid, (name, ticks) in after.items():
if pid not in before or before[pid][0] != name:
continue
delta = max(0, ticks - before[pid][1])
percent = 100 * delta / ticks_per_second / elapsed
if percent >= 1:
cpu.append({'process': name, 'percentOfOneCore': round(percent, 1)})
report['cpuSample'] = {'durationSeconds': round(elapsed, 2),
'busyProcesses': sorted(cpu,
key=lambda item: item['percentOfOneCore'],
reverse=True)[:12]}
busy_threads = []
for key, (name, ticks) in threads_after.items():
prior = threads_before.get(key)
if prior is None or prior[0] != name:
continue
percent = 100 * max(0, ticks-prior[1]) / ticks_per_second / elapsed
if percent >= 1:
busy_threads.append({'processId':key[0], 'threadId':key[1],
'thread':name,
'percentOfOneCore':round(percent,1)})
report['cpuSample']['busyThreads'] = sorted(
busy_threads, key=lambda item:item['percentOfOneCore'], reverse=True)[:20]
idle_after = cpu_idle_snapshot()
residency = {}
for cpu_name, states in idle_after.items():
previous = idle_before.get(cpu_name, [])
before_by_name = {state.get('name'):state for state in previous}
deltas = []
for state in states:
prior = before_by_name.get(state.get('name'), {})
item = {'name':state.get('name')}
for key in ('usage','time'):
if key in state:
try:
item[key+'Delta'] = max(0,int(state[key])-int(prior.get(key,state[key])))
except ValueError:
pass
if 'disable' in state:
item['disabled'] = state['disable']
deltas.append(item)
if deltas:
residency[cpu_name] = deltas
report['cpuIdle'] = {'available':bool(residency),
'statesDuringSample':residency,
'frequencyAfterSample':cpu_frequency_snapshot(),
'kernelCommandLineFlags':[
token for token in Path('/proc/cmdline').read_text().split()
if token.startswith(('lpm_levels.','cpuidle.','nohz='))],
'lowPowerParameter':None,
'systemSuspend':{}}
for key, path in {
'sleepDisabled':'/sys/module/lpm_levels/parameters/sleep_disabled',
'cpuIdleEnabled':'/sys/module/lpm_levels/parameters/enable_lpm',
'powerState':'/sys/power/state',
'memorySleep':'/sys/power/mem_sleep',
}.items():
try:
value = Path(path).read_text().strip()
if key in ('powerState','memorySleep'):
report['cpuIdle']['systemSuspend'][key] = value
else:
report['cpuIdle']['lowPowerParameter'] = {
**(report['cpuIdle']['lowPowerParameter'] or {}), key:value}
except OSError:
pass
suspend_stats = {}
for path in Path('/sys/power/suspend_stats').glob('*'):
if path.is_file():
try:
suspend_stats[path.name] = path.read_text().strip()
except OSError:
pass
report['cpuIdle']['systemSuspend']['statistics'] = suspend_stats or None
wakeups_after = wakeup_snapshot()
report['wakeupSources'] = {
'available':wakeups_after is not None,
'eventDeltas':counter_delta(wakeups_before,wakeups_after,('eventCount',)),
}
report['batteryDelta'] = {}
for supply, values in power_supplies().items():
prior = battery_before.get(supply,{})
for key in ('current_now','voltage_now','power_now','capacity','status'):
if key in values:
report['batteryDelta'].setdefault(supply,{})[key] = {
'before':prior.get(key), 'after':values[key]}
return report

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small helpers for keeping inherited Android boot flags out of Vulpes."""
def remove_parameters(cmdline, names):
"""Return (cleaned_cmdline, removed_tokens) for exact kernel parameter names."""
names = set(names)
kept = []
removed = []
for token in cmdline.split():
if token.split('=', 1)[0] in names:
removed.append(token)
else:
kept.append(token)
return ' '.join(kept), removed
def vulpes_cmdline(cmdline):
# This Qualcomm cpuidle parameter disables every selectable low-power
# level. It is inherited from Android and must not disable Vulpes CPU idle.
return remove_parameters(cmdline, {'lpm_levels.sleep_disabled'})

View file

@ -52,6 +52,7 @@ def main():
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'
files['opt/vulpes/tundra/runtime/beatles-camera-client.py']=ROOT/'tundra/runtime/beatles-camera-client.py'
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

View file

@ -17,6 +17,7 @@ import io
import tarfile
from ramdisk_reference import read_newc
from shared_ui import build_payload, compress_archive
from kernel_cmdline import vulpes_cmdline
from tundra import ROOT, checked_source, cpio, digest, save
@ -46,6 +47,8 @@ def main():
options=shlex.split(text);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0' or digest(temp/'base/kernel')!=build['kernelSha256']:
raise ValueError('Unexpected boot format or kernel')
source_cmdline=fields['--cmdline']
fields['--cmdline'],removed_kernel_parameters=vulpes_cmdline(source_cmdline)
original=read_newc((temp/'base/ramdisk').read_bytes())
if 'shared-ui.tar' not in original or 'standalone-mount.sh' not in original:
raise ValueError('Missing standalone UI overlay')
@ -112,12 +115,13 @@ def main():
for name in hashes:
target=out/'sources'/name;target.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(ROOT.parent/name,target)
for path in (Path(__file__).resolve(),ROOT/'tools/shared_ui.py',*native_sources.values(),*([ROOT/'runtime/prepare-native.py'] if args.settings_adapters else [])):
for path in (Path(__file__).resolve(),ROOT/'tools/kernel_cmdline.py',ROOT/'tools/shared_ui.py',*native_sources.values(),*([ROOT/'runtime/prepare-native.py'] if args.settings_adapters else [])):
target=out/'sources'/path.relative_to(ROOT.parent)
target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target)
build['sources'][str(path.relative_to(ROOT))]=digest(path)
shutil.copy2(base/'client-key',out/'client-key')
build.update(image='boot.img',imageSha256=digest(out/'boot.img'),ramdiskSha256=digest(ramdisk),
removedKernelParameters=removed_kernel_parameters,
sharedUiHashes=hashes,builtAtUnix=int(time.time()),flashReady=False,
baseImageSha256=args.expected_sha256,uiOnlyRefresh=not args.settings_adapters,changedRamdiskEntries=changed,
scope='Private RAM Settings update; kernel and storage configuration preserved')

View file

@ -2,6 +2,7 @@
# SPDX-License-Identifier: MPL-2.0
"""Synchronize the shared host and run a bounded private Nokia Gaia trial."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
@ -15,6 +16,26 @@ OUT=ROOT/'tundra/out/beatles/gecko-156.0.1'
REMOTE='/data/local/vulpes-beatles'
def compile_if_changed(command, output, sources, signature):
"""Avoid relinking the same ARM tools on every bounded trial."""
stamp = output.with_name(output.name + '.build.json')
inputs = []
for path in sources:
stat = path.stat()
digest = hashlib.sha256(path.read_bytes()).hexdigest()
inputs.append({'path':str(path), 'size':stat.st_size, 'sha256':digest})
record = {'command':command,'signature':signature, 'inputs':inputs}
if output.is_file() and stamp.is_file():
try:
if json.loads(stamp.read_text()) == record:
print('Using cached ARM tool:', output.name, flush=True)
return
except (OSError, ValueError):
pass
subprocess.run(command, check=True)
stamp.write_text(json.dumps(record, sort_keys=True)+'\n')
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')
@ -23,17 +44,46 @@ def main():
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')
parser.add_argument('--camera-service-probe',action='store_true',help='Test Android Camera1/CameraService as the camera system UID in a bounded RAM-only trial')
parser.add_argument('--camera-service-probe-binary',type=Path,help='Private Android API 23 Camera1 diagnostic binary')
parser.add_argument('--camera-gl-probe',action='store_true',help='Test Camera1 through EGL/GLConsumer in a bounded RAM-only trial')
parser.add_argument('--camera-gl-probe-binary',type=Path,help='Private Android API 23 EGL/GLConsumer diagnostic binary')
parser.add_argument('--camera-permission-bridge-binary',type=Path,help='Temporary camera-only Android API 23 permission controller')
parser.add_argument('--camera-permission-bridge-runtime',type=Path,help='Android API 23 C++ runtime required by the permission bridge')
parser.add_argument('--camera-broker',action='store_true',help='Run the fixed-command CameraService bridge under UID 1006 during Gaia qualification')
parser.add_argument('--no-camera-broker',action='store_true',help='Disable the CameraService bridge for a non-camera session')
parser.add_argument('--camera-broker-worker',type=Path,help='Private Android API 23 persistent Camera1 worker binary')
parser.add_argument('--camera-broker-binary',type=Path,help='Private Android API 23 local camera broker binary')
parser.add_argument('--display-session-binary',type=Path,help='Use a prebuilt compatible session supervisor')
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')
camera_diagnostics=(args.camera_probe, args.camera_preview_binary is not None,
args.camera_service_probe, args.camera_gl_probe)
camera_broker=args.camera_broker or (not args.no_camera_broker and not any(camera_diagnostics))
camera_modes=sum(camera_diagnostics)+(args.camera_broker is True)
if camera_modes > 1:
parser.error('Choose one camera probe per RAM trial; all use the same sensor')
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 or args.camera_service_probe or args.camera_gl_probe) else '--temporary-240s')
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')
# A previous interrupted run must never make an old frame or log appear
# to belong to this trial. These paths are private diagnostics only.
stale = [REMOTE+'/camera-service-probe-once', REMOTE+'/camera-service-probe.log',
REMOTE+'/camera-gl-probe-once', REMOTE+'/camera-gl-probe.log',
REMOTE+'/camera-permission-bridge.log', REMOTE+'/qcamera-probe-once',
REMOTE+'/camera-broker-once',REMOTE+'/camera-broker.log',REMOTE+'/camera-ipc/camera.sock',
REMOTE+'/qcamera-probe.log', REMOTE+'/qcamera-preview-dump-once',
REMOTE+'/qcamera-preview-dump', REMOTE+'/rootfs/work/camera-frame.yuv',
REMOTE+'/rootfs/work/camera-photo.jpg']
cleanup=subprocess.run(adb+['shell','rm -f '+' '.join(stale)],capture_output=True,text=True)
if cleanup.returncode:
print('Could not clear every prior camera artifact:',cleanup.stderr.strip(),flush=True)
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):
if lid=='1' and not (args.camera_probe or args.camera_preview_binary or args.camera_service_probe or args.camera_gl_probe):
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')
@ -45,12 +95,25 @@ def main():
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)
display_session=OUT/'display-session'
if args.display_session_binary:
if not args.display_session_binary.is_file():raise RuntimeError('Prebuilt session supervisor not found')
display_session=args.display_session_binary
else:
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']
compiler_stat=Path(compiler[0]).stat()
runtime_lock=sysroot.parent/'runtime-lock.json'
signature={'command':compiler,'compilerSize':compiler_stat.st_size,
'compilerMtimeNs':compiler_stat.st_mtime_ns,
'runtimeLockSha256':hashlib.sha256(runtime_lock.read_bytes()).hexdigest()}
compile_if_changed(compiler+['-static',str(ROOT/'tundra/probes/beatles-control-client.c'),'-o',str(OUT/'control-client')],
OUT/'control-client',[ROOT/'tundra/probes/beatles-control-client.c'],signature)
compile_if_changed(compiler+['-static',str(ROOT/'tundra/probes/beatles-display-session.c'),'-o',str(OUT/'display-session')],
OUT/'display-session',[ROOT/'tundra/probes/beatles-display-session.c'],signature)
compile_if_changed(compiler+[str(ROOT/'tundra/probes/beatles-keypad.c'),'-lX11','-lXtst','-o',str(OUT/'keypad')],
OUT/'keypad',[ROOT/'tundra/probes/beatles-keypad.c'],signature)
(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)
@ -65,6 +128,57 @@ def main():
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)
if args.camera_service_probe:
if not args.camera_service_probe_binary or not args.camera_service_probe_binary.is_file():
raise RuntimeError('--camera-service-probe requires --camera-service-probe-binary')
if not args.camera_permission_bridge_binary or not args.camera_permission_bridge_binary.is_file():
raise RuntimeError('--camera-service-probe requires --camera-permission-bridge-binary')
if not args.camera_permission_bridge_runtime or not args.camera_permission_bridge_runtime.is_file():
raise RuntimeError('--camera-service-probe requires --camera-permission-bridge-runtime')
probe='/data/local/tmp/vulpes-camera-probe-20261023'
subprocess.run(adb+['push',str(args.camera_service_probe_binary),probe],check=True,capture_output=True)
bridge='/data/local/tmp/vulpes-camera-permission-bridge'
runtime='/data/local/tmp/libc++_shared.so'
subprocess.run(adb+['push',str(args.camera_permission_bridge_binary),bridge],check=True,capture_output=True)
subprocess.run(adb+['push',str(args.camera_permission_bridge_runtime),runtime],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+probe+' '+bridge+' '+runtime+'; rm -f '+REMOTE+'/camera-permission-bridge.log; touch '+REMOTE+'/camera-service-probe-once'],check=True,capture_output=True)
if args.camera_gl_probe:
if not args.camera_gl_probe_binary or not args.camera_gl_probe_binary.is_file():
raise RuntimeError('--camera-gl-probe requires --camera-gl-probe-binary')
if not args.camera_permission_bridge_binary or not args.camera_permission_bridge_binary.is_file():
raise RuntimeError('--camera-gl-probe requires --camera-permission-bridge-binary')
if not args.camera_permission_bridge_runtime or not args.camera_permission_bridge_runtime.is_file():
raise RuntimeError('--camera-gl-probe requires --camera-permission-bridge-runtime')
probe='/data/local/tmp/vulpes-camera-gl-probe-20261023'
subprocess.run(adb+['push',str(args.camera_gl_probe_binary),probe],check=True,capture_output=True)
bridge='/data/local/tmp/vulpes-camera-permission-bridge'
runtime='/data/local/tmp/libc++_shared.so'
subprocess.run(adb+['push',str(args.camera_permission_bridge_binary),bridge],check=True,capture_output=True)
subprocess.run(adb+['push',str(args.camera_permission_bridge_runtime),runtime],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+probe+' '+bridge+' '+runtime+'; rm -f '+REMOTE+'/camera-permission-bridge.log; touch '+REMOTE+'/camera-gl-probe-once'],check=True,capture_output=True)
if camera_broker:
subprocess.run([sys.executable,str(ROOT/'tundra/tools/build-beatles-camera.py')],check=True)
broker_build=OUT/'camera-broker-build'
worker_path=args.camera_broker_worker or broker_build/'beatles-camera-worker'
broker_path=args.camera_broker_binary or broker_build/'beatles-camera-broker'
permission_bridge_path=args.camera_permission_bridge_binary or OUT/'camera-probe-runtime/beatles-camera-permission-bridge'
permission_runtime_path=args.camera_permission_bridge_runtime or OUT/'camera-probe-runtime/libc++_shared.so'
if not worker_path.is_file():
raise RuntimeError('CameraService worker binary not found: '+str(worker_path))
if not broker_path.is_file():
raise RuntimeError('CameraService broker binary not found: '+str(broker_path))
if not permission_bridge_path.is_file():
raise RuntimeError('Camera permission bridge binary not found: '+str(permission_bridge_path))
if not permission_runtime_path.is_file():
raise RuntimeError('Camera permission bridge runtime not found: '+str(permission_runtime_path))
broker_worker='/data/local/tmp/vulpes-beatles-camera-worker'
broker='/data/local/tmp/vulpes-beatles-camera-broker'
bridge='/data/local/tmp/vulpes-camera-permission-bridge'
runtime='/data/local/tmp/libc++_shared.so'
for source,target in ((worker_path,broker_worker),(broker_path,broker),
(permission_bridge_path,bridge),(permission_runtime_path,runtime)):
subprocess.run(adb+['push',str(source),target],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+broker_worker+' '+broker+' '+bridge+' '+runtime+'; mkdir -p '+REMOTE+'/camera-ipc; rm -f '+REMOTE+'/camera-ipc/camera.sock '+REMOTE+'/camera-permission-bridge.log '+REMOTE+'/camera-broker.log; touch '+REMOTE+'/camera-broker-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'
@ -86,7 +200,9 @@ def main():
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'),
subprocess.run(adb+['shell','mkdir -p '+REMOTE+'/rootfs/opt/vulpes/tundra/runtime'],check=True,capture_output=True)
subprocess.run(adb+['push',str(ROOT/'tundra/runtime/beatles-camera-client.py'),REMOTE+'/rootfs/opt/vulpes/tundra/runtime/beatles-camera-client.py'],check=True,capture_output=True)
files=((OUT/'control-client','rootfs/work/control-client'),(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'),
@ -113,6 +229,9 @@ def main():
# 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 b=Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
b.setBoolPref("devtools.debugger.remote-enabled",true);
Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService).savePrefFile(null);
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))
@ -141,7 +260,7 @@ def main():
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):
for second in range(265):
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)
@ -149,12 +268,19 @@ def main():
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',
for relative in ('display-session.log','qcamera-probe.log','camera-service-probe.log','camera-gl-probe.log','camera-permission-bridge.log','camera-broker.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)
if args.camera_gl_probe:
for remote, name in (('/data/local/tmp/vulpes-camera-glconsumer.ppm','camera-glconsumer.ppm'),
('/data/local/tmp/vulpes-camera-gonkqueue.nv21','camera-gonkqueue.nv21'),
('/data/local/tmp/vulpes-camera-gonkqueue.txt','camera-gonkqueue.txt'),
('/data/local/tmp/vulpes-camera-callback.yuv','camera-callback.yuv'),
('/data/local/tmp/vulpes-camera-callback.jpg','camera-callback.jpg')):
subprocess.run(adb+['pull',remote,str(report/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
@ -170,6 +296,10 @@ def main():
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 args.camera_service_probe:
subprocess.run(adb+['shell','rm -f /data/local/tmp/vulpes-camera-probe-20261023 /data/local/tmp/vulpes-camera-permission-bridge /data/local/tmp/libc++_shared.so /data/local/tmp/vulpes-camera-frame.nv21'],capture_output=True)
if args.camera_gl_probe:
subprocess.run(adb+['shell','rm -f /data/local/tmp/vulpes-camera-gl-probe-20261023 /data/local/tmp/vulpes-camera-permission-bridge /data/local/tmp/libc++_shared.so'],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')}

View file

@ -51,6 +51,8 @@ def selected_files():
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT/'mms-codec').glob('*')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'assets/webapps/system.gaiamobile.org/style/system/system.css',
ROOT.parent/'assets/webapps/system.gaiamobile.org/style/system/initlogo.css',
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
@ -64,6 +66,8 @@ def selected_files():
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js',
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/screen_lock_passcode/panel.js',
ROOT.parent/'assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/verticalhome.gaiamobile.org/js/init.json',
ROOT.parent/'gaia/apps/verticalhome/js/sources/application.js',
ROOT.parent/'assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',

View file

@ -9,6 +9,18 @@ def mount_table(layout):
raise ValueError('Expected Sargo userdata, not another partition')
if not re.fullmatch(r'[0-9a-f]{64}', layout.get('firstMiBSha256', '')):
raise ValueError('Missing userdata identity checksum')
if layout.get('schema') == 2 and layout.get('table') == 'direct':
if (layout.get('partitionLabel') != 'userdata' or
layout.get('filesystem') != 'ext4' or
layout.get('storage') != 'raw-userdata-ext4' or
not re.fullmatch(r'[0-9]+:[0-9]+', layout.get('backingDevice', '')) or
not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
layout.get('filesystemUuid', ''))):
raise ValueError('Direct mapping requires inventoried raw ext4 userdata')
size = int(layout.get('size', 0))
if size <= 0 or size % 512:
raise ValueError('Invalid direct userdata geometry')
return None
match = re.fullmatch(r'0 ([0-9]+) linear ([0-9]+:[0-9]+) ([0-9]+)',
layout.get('table', ''))
if not match:

View file

@ -36,11 +36,34 @@ def main():
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}
out=ROOT/'tundra/out/beatles/gecko-156.0.1'
cache_path=out/'.gaia-sync-hash-cache.json'
try:
cache=json.loads(cache_path.read_text()).get('files',{})
except (OSError,ValueError,AttributeError):
cache={}
current={}
next_cache={}
reused=0
for path in files:
name=str(path.relative_to(ROOT))
stat=path.stat()
fingerprint={'device':stat.st_dev,'inode':stat.st_ino,'size':stat.st_size,
'mtime_ns':stat.st_mtime_ns,'ctime_ns':stat.st_ctime_ns}
previous=cache.get(name,{})
digest=previous.get('sha256') if all(previous.get(k)==v for k,v in fingerprint.items()) else None
if not digest:
digest=hashlib.sha256(path.read_bytes()).hexdigest()
else:
reused+=1
current[name]=digest
next_cache[name]={**fingerprint,'sha256':digest}
temporary=cache_path.with_suffix('.tmp')
temporary.write_text(json.dumps({'files':next_cache},separators=(',',':'))+'\n')
temporary.replace(cache_path)
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:
@ -57,6 +80,6 @@ def main():
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}))
print(json.dumps({'files':len(current),'hashesReused':reused,'updated':len(changed),'removed':len(removed),'profilePreserved':True}))
if __name__=='__main__':main()