Release Vulpes OS 2.7-preview.24
This commit is contained in:
parent
49572b6b45
commit
97ef99f827
135 changed files with 7717 additions and 2524 deletions
49
tundra/tools/battery-idle-log.py
Normal file
49
tundra/tools/battery-idle-log.py
Normal 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()
|
||||
64
tundra/tools/build-beatles-camera.py
Normal file
64
tundra/tools/build-beatles-camera.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
22
tundra/tools/kernel_cmdline.py
Normal file
22
tundra/tools/kernel_cmdline.py
Normal 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'})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue