Update shared Gaia workflows and Sargo development tooling
Fix camera switching and Contacts/SMS activity routing across shared hosts. Integrate Marketplace installation and native standalone recovery tools, Bluetooth runtime packages and trusted certificates. Update English/French READMEs and the shared changelog with tested scope and remaining radio/audio/Bluetooth limitations. Preview17 remains the existing APK; no new native image is released. Validation: shared JavaScript and native Python tests; Android packaging and payload parity from an isolated publication checkout.
This commit is contained in:
parent
d04824ec89
commit
0041cd9660
101 changed files with 3883 additions and 335 deletions
46
tundra/boot/sparse-backup.c
Normal file
46
tundra/boot/sparse-backup.c
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0
|
||||
* Read stdin only. Emit offsets and nonzero 64 KiB blocks for a host-side backup.
|
||||
* No device is opened or written by this process.
|
||||
*/
|
||||
#define _start unused_stage0_start
|
||||
#include "init.c"
|
||||
#undef _start
|
||||
static unsigned long buffer[8192];
|
||||
static void stop(long code) {
|
||||
call(NR_exit, code, 0, 0, 0, 0);
|
||||
for (;;) {}
|
||||
}
|
||||
static void write_all(const void *data, long length) {
|
||||
const char *p = data;
|
||||
while (length) {
|
||||
long n = call(NR_write, 1, (long)p, length, 0, 0);
|
||||
if (n == -4) continue;
|
||||
if (n <= 0) stop(3);
|
||||
p += n;
|
||||
length -= n;
|
||||
}
|
||||
}
|
||||
__attribute__((noreturn)) void _start(void) {
|
||||
unsigned long offset = 0;
|
||||
write_all("TUNRAW1\0", 8);
|
||||
for (;;) {
|
||||
long n = call(NR_read, 0, (long)buffer, sizeof(buffer), 0, 0);
|
||||
if (n == -4) continue;
|
||||
if (n < 0) stop(2);
|
||||
if (!n) break;
|
||||
unsigned long bits = 0;
|
||||
long i = 0;
|
||||
for (; i + 8 <= n; i += 8) bits |= buffer[i / 8];
|
||||
for (; i < n; ++i) bits |= ((unsigned char *)buffer)[i];
|
||||
if (bits) {
|
||||
unsigned long header[2] = {offset, (unsigned long)n};
|
||||
write_all(header, sizeof(header));
|
||||
write_all(buffer, n);
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
unsigned long end[2] = {offset, 0};
|
||||
write_all(end, sizeof(end));
|
||||
call(NR_exit, 0, 0, 0, 0, 0);
|
||||
for (;;) {}
|
||||
}
|
||||
14
tundra/boot/standalone-mount.sh
Normal file
14
tundra/boot/standalone-mount.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/busybox sh
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
# Mount only the prepared Tundra volume. Never format or guess an old filesystem.
|
||||
set -eu
|
||||
# VOLUME_CONFIGURATION
|
||||
mkdir -p /volume
|
||||
blkid "$VOLUME_DEVICE" | grep -F "UUID=\"$VOLUME_UUID\"" >/dev/null
|
||||
repaired=0
|
||||
e2fsck -p "$VOLUME_DEVICE" || repaired=$?
|
||||
test "$repaired" -le 1
|
||||
mount -t ext4 -o "$VOLUME_OPTIONS" "$VOLUME_DEVICE" /volume
|
||||
test "$(cat /volume/tundra-volume-id)" = "$VOLUME_UUID"
|
||||
echo TUNDRA_STANDALONE_VOLUME_READY
|
||||
exec /bin/sh /native-root.sh
|
||||
|
|
@ -67,8 +67,10 @@ export async function run(win) {
|
|||
.then(async response=>dump(`TUNDRA_HTTP ${response.status} ${(await response.text()).length}\n`))
|
||||
.catch(error=>dump('TUNDRA_HTTP_ERROR '+error+' offline='+Services.io.offline+'\n'));
|
||||
async function evaluate(app, code, message = 'Evaluate') {
|
||||
const prefix=app==='contacts'?'http://communications.localhost:8765/contacts/':
|
||||
app==='dialer'?'http://communications.localhost:8765/dialer/':`http://${app}.localhost:8765/`;
|
||||
function find(context) {
|
||||
if(context.currentWindowGlobal?.documentURI.spec.startsWith(`http://${app}.localhost:8765/`)) return context;
|
||||
if(context.currentWindowGlobal?.documentURI.spec.startsWith(prefix)) return context;
|
||||
for(const child of context.children) {const found=find(child); if(found) return found;}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -90,14 +92,15 @@ export async function run(win) {
|
|||
}
|
||||
throw new Error(`${app}: timeout: ${last}`);
|
||||
}
|
||||
async function tap(app, selector) {
|
||||
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
|
||||
await wait(app, `const e=document.querySelector(${JSON.stringify(selector)});if(!e)return false;const r=e.getBoundingClientRect();return r.width>0 && r.height>0 && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));`);
|
||||
await sleep(600);
|
||||
const point = await evaluate(app, `const e=document.querySelector(${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};`);
|
||||
await evaluate(app,point,'Tap');
|
||||
await sleep(350);
|
||||
}
|
||||
async function tap(app, selector) {
|
||||
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
|
||||
const point=await wait(app, `for(const e of document.querySelectorAll(${JSON.stringify(selector)})) {
|
||||
const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;
|
||||
if(x>0 && y>0 && x<innerWidth && y<innerHeight && e.contains(document.elementFromPoint(x,y)))return {x,y};
|
||||
}return false;`);
|
||||
await evaluate(app,point,'Tap');
|
||||
await sleep(350);
|
||||
}
|
||||
try {
|
||||
const lock=await IOUtils.readJSON(`${root}/engine-lock.json`);
|
||||
if(report.engine.version!==lock.version || !report.engine.ABI.startsWith('aarch64'))
|
||||
|
|
@ -229,241 +232,7 @@ export async function run(win) {
|
|||
}
|
||||
// Fixed diagnostics can be requested again over the private maintenance SSH.
|
||||
// Markers never contain scripts or arbitrary commands.
|
||||
async function requestedUiChecks() {
|
||||
const cameraMarker = `${root}/logs/test-camera`;
|
||||
if (await IOUtils.exists(cameraMarker)) {
|
||||
await IOUtils.remove(cameraMarker);
|
||||
const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
|
||||
await wait('system', "return !Service.query('locked');");
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
|
||||
// Gaia remembers the selected lens; establish a known starting state.
|
||||
await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
|
||||
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
cameraReport.gaia = await evaluate('camera', `
|
||||
const video=document.querySelector('video');
|
||||
const camera=app.camera.mozCamera;
|
||||
window.__cameraBeforeSwitch=camera;
|
||||
const capabilities=camera.capabilities;
|
||||
let focus=null;
|
||||
if(capabilities.maxFocusAreas>0) {
|
||||
camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
|
||||
await camera.autoFocus();focus=true;
|
||||
}
|
||||
if(capabilities.zoomRatios.length>1) {
|
||||
camera.zoom=2;
|
||||
await new Promise(r=>setTimeout(r,700));
|
||||
}
|
||||
const testedZoom=camera.zoom;
|
||||
camera.zoom=1;
|
||||
const photo=await camera.takePicture();
|
||||
const bitmap=await createImageBitmap(photo);
|
||||
const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
|
||||
return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
|
||||
picture,focus,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
|
||||
jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
|
||||
`);
|
||||
cameraReport.performance = await evaluate('camera', `
|
||||
const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
|
||||
const start=performance.now();await new Promise(r=>setTimeout(r,4000));
|
||||
const elapsed=performance.now()-start;
|
||||
const hud=document.querySelector('.hud');
|
||||
const button=document.querySelector('.js-camera');
|
||||
return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
|
||||
conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
|
||||
switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
|
||||
flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
|
||||
`);
|
||||
await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
cameraReport.switched = await evaluate('camera', `
|
||||
const photo=await app.camera.mozCamera.takePicture();
|
||||
window.__cameraBeforeHome=app.camera.mozCamera;
|
||||
return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
|
||||
previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
|
||||
`);
|
||||
cameraReport.roundTrips = [];
|
||||
for (let i=0; i<4; i++) {
|
||||
await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
|
||||
await tap('camera', '.js-camera');
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
cameraReport.roundTrips.push(await evaluate('camera', `
|
||||
const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
|
||||
await new Promise(r=>setTimeout(r,700));
|
||||
return {camera:app.settings.cameras.selected('key'),
|
||||
frames:camera.vulpesTiming.frames-before,
|
||||
previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
|
||||
`));
|
||||
}
|
||||
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
|
||||
// Test Gaia's Home action, independent of a held pointer on the shell button.
|
||||
cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
|
||||
await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
|
||||
cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
|
||||
cameraReport.hidden = await wait('camera', 'return document.hidden;');
|
||||
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
|
||||
cameraReport.passed=cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
|
||||
} catch(error) { cameraReport.error=String(error); }
|
||||
finally { win.document.getElementById('home')?.click(); }
|
||||
await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
|
||||
}
|
||||
const smsMarker = `${root}/logs/test-sms-display`;
|
||||
if (await IOUtils.exists(smsMarker)) {
|
||||
await IOUtils.remove(smsMarker);
|
||||
const smsReport = {bootId:report.bootId, passed:false, sent:false};
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
|
||||
smsReport.threads = await evaluate('sms', `
|
||||
const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
|
||||
window.__smsReviewThreads=threads.map(t=>t.id);
|
||||
return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
|
||||
`);
|
||||
smsReport.conversations=[];
|
||||
for(let i=0;i<Math.min(smsReport.threads.count,5);i++) {
|
||||
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
|
||||
await sleep(1500);
|
||||
smsReport.conversations.push(await evaluate('sms', `
|
||||
const nodes=[...document.querySelectorAll('#messages-container .message')];
|
||||
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.isFinite(+e.querySelector('time')?.dataset.time)),
|
||||
nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
|
||||
errorCount:window.__vulpesDiagnostics?.length||0};
|
||||
`));
|
||||
}
|
||||
smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
|
||||
} catch(error) {smsReport.error=String(error);}
|
||||
finally {win.document.getElementById('home')?.click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
|
||||
}
|
||||
const inputMarker = `${root}/logs/test-keyboard`;
|
||||
if (await IOUtils.exists(inputMarker)) {
|
||||
await IOUtils.remove(inputMarker);
|
||||
const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
|
||||
try {
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
await sleep(1500);
|
||||
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
|
||||
await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
|
||||
await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
|
||||
await tap('sms','.recipient[contenteditable]');
|
||||
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
|
||||
await tap('keyboard','button[aria-label="2"]');
|
||||
await tap('keyboard','button[aria-label="3"]');
|
||||
inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
|
||||
const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
|
||||
await tap('sms','#messages-input');
|
||||
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
|
||||
inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
|
||||
window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
|
||||
return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
|
||||
await tap('keyboard','button[aria-label="a"]');
|
||||
await sleep(2000);
|
||||
inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
|
||||
inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
|
||||
inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
|
||||
inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
|
||||
inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
|
||||
inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
|
||||
if(!inputReport.messageTyped) {
|
||||
try {
|
||||
await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
|
||||
inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
|
||||
} catch(error) {inputReport.directKeyError=String(error);}
|
||||
}
|
||||
inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
|
||||
for (const face of inputReport.fonts) delete face.samples;
|
||||
} catch(error) {inputReport.error=String(error);}
|
||||
finally {win.document.getElementById('home').click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
|
||||
}
|
||||
const sendMarker=`${root}/logs/test-send-sms.json`;
|
||||
if(await IOUtils.exists(sendMarker)) {
|
||||
const request=await IOUtils.readJSON(sendMarker);
|
||||
await IOUtils.remove(sendMarker);
|
||||
const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
|
||||
try {
|
||||
if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
|
||||
const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
|
||||
const state=await tundraRadio('status');
|
||||
if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
await sleep(1000);
|
||||
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms','return !!window.Navigation;');
|
||||
await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
|
||||
await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
|
||||
await tap('sms','.recipient[contenteditable]');
|
||||
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
|
||||
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
|
||||
await tap('sms','#messages-input');
|
||||
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
|
||||
const body='Vulpes OS - test SMS '+new Date().toISOString();
|
||||
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
|
||||
await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
|
||||
await evaluate('sms',`if(!Navigation.isCurrentPanel('composer') ||
|
||||
JSON.stringify(ConversationView.recipients.numbers)!==JSON.stringify([${JSON.stringify(request.recipient)}]) ||
|
||||
Compose.getText()!==${JSON.stringify(body)})throw Error('TEST_MESSAGE_MISMATCH');
|
||||
document.querySelector('#messages-send-button').click();return true;`);
|
||||
result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
|
||||
const cursor=navigator.mozMobileMessage.getMessages(null,true);
|
||||
cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
|
||||
if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
|
||||
cursor.onerror=()=>reject(cursor.error);
|
||||
});`);
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {win.document.getElementById('home').click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
|
||||
}
|
||||
const notificationMarker = `${root}/logs/test-notifications`;
|
||||
if (await IOUtils.exists(notificationMarker)) {
|
||||
await IOUtils.remove(notificationMarker);
|
||||
const result = {bootId:report.bootId,passed:false,silent:true};
|
||||
let id;
|
||||
try {
|
||||
await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms', 'return typeof Notification.get === "function";');
|
||||
await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
|
||||
result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
|
||||
await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
|
||||
id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
|
||||
const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
|
||||
n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
|
||||
});`);
|
||||
const selector = `[data-notification-id="${id}"]`;
|
||||
result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
|
||||
result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
|
||||
await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
|
||||
result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
|
||||
result.passed = result.tray && result.lockscreen && result.removed;
|
||||
} catch(error) { result.error=String(error); }
|
||||
finally {
|
||||
if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
|
||||
win.document.getElementById('home').click();
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
|
||||
}
|
||||
}
|
||||
await requestedUiChecks();
|
||||
let uiCheckRunning=false;
|
||||
const uiCheckTimer=setInterval(async()=>{
|
||||
if(uiCheckRunning) return;
|
||||
uiCheckRunning=true;
|
||||
try {
|
||||
const reloadMarker=`${root}/logs/test-reload-gaia`;
|
||||
if(await IOUtils.exists(reloadMarker)) {
|
||||
await IOUtils.remove(reloadMarker);
|
||||
win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
|
||||
await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
|
||||
await sleep(1500);
|
||||
}
|
||||
await requestedUiChecks();
|
||||
} catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
|
||||
finally {uiCheckRunning=false;}
|
||||
},3000);
|
||||
win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
|
||||
async function requestedControls() {
|
||||
const controlsMarker = `${root}/logs/test-controls`;
|
||||
if (await IOUtils.exists(controlsMarker)) {
|
||||
await IOUtils.remove(controlsMarker);
|
||||
|
|
@ -569,6 +338,392 @@ export async function run(win) {
|
|||
controls.brightness=brightnessCheck;
|
||||
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls);
|
||||
}
|
||||
}
|
||||
async function requestedUiChecks() {
|
||||
await requestedControls();
|
||||
const marketplaceMarker = `${root}/logs/test-marketplace-views`;
|
||||
if (await IOUtils.exists(marketplaceMarker)) {
|
||||
await IOUtils.remove(marketplaceMarker);
|
||||
const result = {bootId:report.bootId,passed:false,checks:{},fixture:'http://127.0.0.1:8767/'};
|
||||
const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
|
||||
const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
|
||||
const until=async check=>{
|
||||
for(let i=0;i<80;i++){if(await check())return true;await sleep(250);}
|
||||
throw Error('Marketplace view check timed out');
|
||||
};
|
||||
const visible=()=>[...Views.entries.values()].some(e=>e.browser.style.visibility==='visible');
|
||||
const open=async()=>{
|
||||
await evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://marketplace.localhost:8765/manifest.webapp"});return true;');
|
||||
await wait('marketplace', 'return !!document.getElementById("browse");');
|
||||
await sleep(1000);
|
||||
await evaluate('marketplace', 'document.getElementById("browse").click();document.getElementById("catalogue").src="http://127.0.0.1:8767/";return true;');
|
||||
await until(visible);
|
||||
};
|
||||
try {
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
await sleep(1000);
|
||||
// This fixture tests web-view ownership, not the lockscreen gesture.
|
||||
// Service.unlock can leave the legacy overlay visible during clock teardown.
|
||||
result.unlockFixture=await evaluate('system', 'let note=null;if(window.lockScreen){try{if(lockScreen.locked)lockScreen.unlock(true);}catch(e){note=String(e);}lockScreen.locked=false;lockScreen.overlay.classList.add("unlocked");}return {forced:true,cleanupError:note};');
|
||||
await open();
|
||||
result.checks.belowStatusBar=[...Views.entries.values()].filter(e=>e.browser.style.visibility==='visible').every(e=>parseFloat(e.browser.style.top)>=20);
|
||||
await evaluate('marketplace', 'document.getElementById("catalogue").src="https://marketplace-offline.invalid/";return true;');
|
||||
result.checks.offlineMessage=await wait('marketplace', 'return !document.getElementById("message").hidden && /inaccessible|unavailable/.test(document.getElementById("status").textContent);');
|
||||
result.checks.errorHidden=await until(()=>!visible());
|
||||
await open();
|
||||
result.checks.retry=true;
|
||||
win.document.getElementById('home').click();
|
||||
result.checks.home=await until(()=>!visible());
|
||||
await sleep(1000);
|
||||
await open();
|
||||
Runtime.broadcast('holdhome',{},'system');
|
||||
await wait('system','return appWindowManager.taskManager.isActive();');
|
||||
result.checks.taskSwitcher=await until(()=>!visible());
|
||||
await sleep(1000);
|
||||
await evaluate('system', 'const row=[...appWindowManager.taskManager.appToCardMap].find(([app])=>app.origin.includes("marketplace.localhost"));row[1].element.querySelector(".close-button").click();return true;');
|
||||
result.checks.closed=await until(()=>Views.entries.size===0);
|
||||
result.passed=Object.values(result.checks).every(Boolean);
|
||||
} catch(error) {
|
||||
result.error=String(error);
|
||||
result.views=[...Views.entries.values()].map(e=>({geometry:e.geometry,style:e.browser.style.cssText}));
|
||||
} finally {win.document.getElementById('home').click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/marketplace-views.json`,result);
|
||||
}
|
||||
const ticketMarker = `${root}/logs/test-tickets-20261005`;
|
||||
if (await IOUtils.exists(ticketMarker)) {
|
||||
await IOUtils.remove(ticketMarker);
|
||||
const result = {bootId:report.bootId,passed:false,checks:{}};
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
|
||||
await wait('system', "return !Service.query('locked');");
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('settings', "return document.readyState==='complete';");
|
||||
await evaluate('settings', "window.__ticketLanguage=(await VulpesCompat.call('settings.get',{key:'language.current'})).value;return true;");
|
||||
for (const lang of ['en-US','fr']) {
|
||||
await evaluate('settings', `await VulpesCompat.call('settings.set',{values:{'language.current':${JSON.stringify(lang)}}});return true;`);
|
||||
result.checks['language_'+lang] = await wait('homescreen', `return navigator.language===${JSON.stringify(lang)} && document.documentElement.lang===${JSON.stringify(lang)};`);
|
||||
}
|
||||
result.checks.developmentHidden = await evaluate('homescreen', "return [...document.querySelectorAll('gaia-app-icon')].every(e=>!/(uitest|test-|membuster|template|ds-test)/.test(e.app?.origin || ''));");
|
||||
result.checks.snap = await evaluate('homescreen', "return getComputedStyle(document.getElementById('panels')).scrollSnapType==='x mandatory';");
|
||||
await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'image/*'}});return true;");
|
||||
await wait('wallpaper', "return !!window.Wallpaper?.pickActivity && document.querySelectorAll('.wallpaper').length>1;");
|
||||
await evaluate('wallpaper', "document.querySelectorAll('.wallpaper')[1].click();return true;");
|
||||
result.checks.wallpaperPicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
|
||||
await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'alerttone'}});return true;");
|
||||
await wait('ringtones', "return document.body.dataset.ready==='true' && document.querySelectorAll('gaia-radio').length>3;");
|
||||
await evaluate('ringtones', "document.querySelectorAll('gaia-radio')[2].click();return true;");
|
||||
await wait('ringtones', "return !document.getElementById('set').disabled;");
|
||||
await evaluate('ringtones', "document.getElementById('set').click();return true;");
|
||||
result.checks.tonePicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
|
||||
result.passed=Object.values(result.checks).every(Boolean);
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {
|
||||
await evaluate('settings', "if(window.__ticketLanguage)await VulpesCompat.call('settings.set',{values:{'language.current':__ticketLanguage}});return true;").catch(()=>{});
|
||||
win.document.getElementById('home').click();
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/tickets-20261005.json`,result);
|
||||
}
|
||||
const cameraMarker = `${root}/logs/test-camera`;
|
||||
if (await IOUtils.exists(cameraMarker)) {
|
||||
await IOUtils.remove(cameraMarker);
|
||||
const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
|
||||
await wait('system', "return !Service.query('locked');");
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
|
||||
// Gaia remembers the selected lens; establish a known starting state.
|
||||
await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
|
||||
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
cameraReport.gaia = await evaluate('camera', `
|
||||
const video=document.querySelector('video');
|
||||
const camera=app.camera.mozCamera;
|
||||
window.__cameraBeforeSwitch=camera;
|
||||
const capabilities=camera.capabilities;
|
||||
let focus=null, focusError=null;
|
||||
if(capabilities.maxFocusAreas>0) {
|
||||
try {
|
||||
camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
|
||||
await camera.autoFocus();focus=true;
|
||||
} catch(error) { focus=false;focusError=String(error); }
|
||||
}
|
||||
if(capabilities.zoomRatios.length>1) {
|
||||
camera.zoom=2;
|
||||
await new Promise(r=>setTimeout(r,700));
|
||||
}
|
||||
const testedZoom=camera.zoom;
|
||||
camera.zoom=1;
|
||||
const photo=await camera.takePicture();
|
||||
const bitmap=await createImageBitmap(photo);
|
||||
const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
|
||||
return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
|
||||
picture,focus,focusError,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
|
||||
jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
|
||||
`);
|
||||
cameraReport.performance = await evaluate('camera', `
|
||||
const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
|
||||
const start=performance.now();await new Promise(r=>setTimeout(r,4000));
|
||||
const elapsed=performance.now()-start;
|
||||
const hud=document.querySelector('.hud');
|
||||
const button=document.querySelector('.js-camera');
|
||||
return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
|
||||
conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
|
||||
switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
|
||||
flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
|
||||
`);
|
||||
await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
cameraReport.switched = await evaluate('camera', `
|
||||
const photo=await app.camera.mozCamera.takePicture();
|
||||
window.__cameraBeforeHome=app.camera.mozCamera;
|
||||
return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
|
||||
previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
|
||||
`);
|
||||
cameraReport.roundTrips = [];
|
||||
for (let i=0; i<4; i++) {
|
||||
await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
|
||||
await tap('camera', '.js-camera');
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
cameraReport.roundTrips.push(await evaluate('camera', `
|
||||
const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
|
||||
await new Promise(r=>setTimeout(r,700));
|
||||
return {camera:app.settings.cameras.selected('key'),
|
||||
frames:camera.vulpesTiming.frames-before,
|
||||
previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
|
||||
`));
|
||||
}
|
||||
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
|
||||
// Test Gaia's Home action, independent of a held pointer on the shell button.
|
||||
await evaluate('camera', "window.__cameraFilesBefore=new Set((await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'})).map(f=>f.name));return true;");
|
||||
await tap('camera','.js-capture');
|
||||
await wait('camera', "const files=await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'});window.__cameraSavedPhoto=files.find(f=>!__cameraFilesBefore.has(f.name));return !!__cameraSavedPhoto;");
|
||||
cameraReport.savedPhoto=await evaluate('camera', 'return {name:__cameraSavedPhoto.name,size:__cameraSavedPhoto.size,type:__cameraSavedPhoto.type};');
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('gallery', `return typeof files!=='undefined' && files.some(f=>f.name===${JSON.stringify(cameraReport.savedPhoto.name)} && f.metadata.width>0 && f.metadata.thumbnail);`);
|
||||
await evaluate('gallery', `document.querySelector('[data-filename="'+${JSON.stringify(cameraReport.savedPhoto.name)}+'"]')?.click();return true;`);
|
||||
cameraReport.galleryOpened=await wait('gallery', "return document.body.classList.contains('fullscreenView');");
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('camera', "return !!app.camera.mozCamera && !app.camera.isBusy && !document.hidden;");
|
||||
await evaluate('camera', `await navigator.getDeviceStorage('pictures').delete(${JSON.stringify(cameraReport.savedPhoto.name)});window.__cameraBeforeHome=app.camera.mozCamera;return true;`);
|
||||
cameraReport.captureStored=cameraReport.savedPhoto.size>1000;
|
||||
delete cameraReport.savedPhoto.name;
|
||||
cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
|
||||
await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
|
||||
cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
|
||||
cameraReport.hidden = await wait('camera', 'return document.hidden;');
|
||||
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
|
||||
cameraReport.passed=cameraReport.captureStored && cameraReport.galleryOpened && cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
|
||||
} catch(error) { cameraReport.error=String(error); }
|
||||
finally { win.document.getElementById('home')?.click(); }
|
||||
await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
|
||||
}
|
||||
const contactsMarker=`${root}/logs/test-contacts`;
|
||||
if(await IOUtils.exists(contactsMarker)) {
|
||||
await IOUtils.remove(contactsMarker);
|
||||
const result={bootId:report.bootId,passed:false}, name='Vulpes QA '+Date.now();
|
||||
let id;
|
||||
const lookup=`const r=navigator.mozContacts.find({filterBy:['givenName'],filterOp:'equals',filterValue:${JSON.stringify(name)}});
|
||||
return await new Promise((ok,no)=>{r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});`;
|
||||
const field=async(selector,value)=>evaluate('contacts',`const e=document.querySelector(${JSON.stringify(selector)});
|
||||
e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;`);
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;");
|
||||
await wait('contacts', "return typeof Loader!=='undefined' && window.MainNavigation?.currentView()==='view-contacts-list';");
|
||||
await tap('contacts','#add-contact-button');
|
||||
await wait('contacts', "return MainNavigation.currentView()==='view-contact-form' && !!document.querySelector('#number_0');");
|
||||
await field('#givenName',name);await field('#familyName','Élodie Test');
|
||||
await field('#number_0','5550199');await field('#email_0','qa@example.invalid');
|
||||
await tap('contacts','#save-button');
|
||||
const saved=await wait('contacts',lookup);id=saved.id;
|
||||
result.created=saved.tel[0].value==='5550199';
|
||||
const selector='[data-uuid="'+id+'"]';
|
||||
await tap('contacts',selector);await tap('contacts','#edit-contact-button');
|
||||
await wait('contacts', "return MainNavigation.currentView()==='view-contact-form';");
|
||||
await field('#familyName','Élodie Modifiée');await tap('contacts','#save-button');
|
||||
result.edited=await wait('contacts', "return document.querySelector('#contact-name-title')?.textContent.includes('Élodie Modifiée');");
|
||||
await evaluate('contacts', "document.querySelector('#send-sms-button-0').scrollIntoView({block:'center'});return true;");
|
||||
await tap('contacts','#send-sms-button-0');
|
||||
result.sms=await wait('sms', "return !document.hidden && window.ConversationView?.recipients?.numbers.includes('5550199');");
|
||||
await tap('sms','#messages-contact-pick-button');
|
||||
await wait('contacts', 'return !document.hidden && ActivityHandler.currentlyHandling;');
|
||||
await tap('contacts',selector);await tap('contacts','[data-l10n-id=pick_destination]');
|
||||
await wait('contacts','return !ActivityHandler.currentlyHandling;');
|
||||
result.picker=await wait('sms', "return !document.hidden && ConversationView.recipients.numbers.includes('5550199');");
|
||||
await evaluate('sms', "new MozActivity({name:'dial',data:{type:'webtelephony/number',number:'5550199'}});return true;");
|
||||
result.dialer=await wait('dialer', "return !document.hidden && document.querySelector('#phone-number-view')?.value==='5550199';");
|
||||
result.passed=result.created&&result.edited&&result.sms&&result.picker&&result.dialer;
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {
|
||||
try {
|
||||
if(!id) id=(await evaluate('contacts',lookup))?.id;
|
||||
if(id) {
|
||||
await evaluate('contacts', `const r=navigator.mozContacts.remove(${JSON.stringify(id)});await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;`);
|
||||
result.temporaryContactRemoved=!(await evaluate('contacts',lookup));
|
||||
}
|
||||
} catch(error) {result.cleanupError=String(error);}
|
||||
win.document.getElementById('home')?.click();
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/contacts.json`,result);
|
||||
}
|
||||
const smsMarker = `${root}/logs/test-sms-display`;
|
||||
if (await IOUtils.exists(smsMarker)) {
|
||||
await IOUtils.remove(smsMarker);
|
||||
const smsReport = {bootId:report.bootId, passed:false, sent:false};
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
|
||||
smsReport.threads = await evaluate('sms', `
|
||||
const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
|
||||
window.__smsReviewThreads=threads.map(t=>t.id);
|
||||
return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
|
||||
`);
|
||||
smsReport.conversations=[];
|
||||
for(let i=0;i<Math.min(smsReport.threads.count,5);i++) {
|
||||
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
|
||||
await sleep(1500);
|
||||
smsReport.conversations.push(await evaluate('sms', `
|
||||
const nodes=[...document.querySelectorAll('#messages-container .message')];
|
||||
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.isFinite(+e.querySelector('time')?.dataset.time)),
|
||||
nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
|
||||
errorCount:window.__vulpesDiagnostics?.length||0};
|
||||
`));
|
||||
}
|
||||
smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
|
||||
} catch(error) {smsReport.error=String(error);}
|
||||
finally {win.document.getElementById('home')?.click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
|
||||
}
|
||||
const inputMarker = `${root}/logs/test-keyboard`;
|
||||
if (await IOUtils.exists(inputMarker)) {
|
||||
await IOUtils.remove(inputMarker);
|
||||
const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
|
||||
try {
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
await sleep(1500);
|
||||
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
|
||||
await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
|
||||
await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
|
||||
await tap('sms','.recipient[contenteditable]');
|
||||
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
|
||||
await tap('keyboard','button[aria-label="2"]');
|
||||
await tap('keyboard','button[aria-label="3"]');
|
||||
inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
|
||||
const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
|
||||
await tap('sms','#messages-input');
|
||||
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
|
||||
inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
|
||||
window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
|
||||
return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
|
||||
await tap('keyboard','button[aria-label="a"]');
|
||||
await sleep(2000);
|
||||
inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
|
||||
inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
|
||||
inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
|
||||
inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
|
||||
inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
|
||||
inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
|
||||
if(!inputReport.messageTyped) {
|
||||
try {
|
||||
await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
|
||||
inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
|
||||
} catch(error) {inputReport.directKeyError=String(error);}
|
||||
}
|
||||
inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
|
||||
for (const face of inputReport.fonts) delete face.samples;
|
||||
} catch(error) {inputReport.error=String(error);}
|
||||
finally {win.document.getElementById('home').click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
|
||||
}
|
||||
const sendMarker=`${root}/logs/test-send-sms.json`;
|
||||
if(await IOUtils.exists(sendMarker)) {
|
||||
const request=await IOUtils.readJSON(sendMarker);
|
||||
await IOUtils.remove(sendMarker);
|
||||
const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
|
||||
try {
|
||||
if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
|
||||
const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
|
||||
const state=await tundraRadio('status');
|
||||
if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
await sleep(1000);
|
||||
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms','return !!window.Navigation;');
|
||||
await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
|
||||
await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
|
||||
await tap('sms','.recipient[contenteditable]');
|
||||
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
|
||||
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
|
||||
await tap('sms','#messages-input');
|
||||
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
|
||||
const body='Vulpes OS - test SMS '+new Date().toISOString();
|
||||
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
|
||||
await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
|
||||
result.preSend=await evaluate('sms',`return {composer:Navigation.isCurrentPanel('composer'),
|
||||
recipientMatch:JSON.stringify(ConversationView.recipients.numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
|
||||
recipientCount:ConversationView.recipients.numbers.length,
|
||||
bodyMatch:Compose.getText()===${JSON.stringify(body)},bodyLength:Compose.getText().length,
|
||||
expectedLength:${body.length}};`);
|
||||
if(!result.preSend.composer || !result.preSend.recipientMatch || !result.preSend.bodyMatch)
|
||||
throw Error('TEST_MESSAGE_MISMATCH');
|
||||
await evaluate('sms',"document.querySelector('#messages-send-button').click();return true;");
|
||||
result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
|
||||
const cursor=navigator.mozMobileMessage.getMessages(null,true);
|
||||
cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
|
||||
if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
|
||||
cursor.onerror=()=>reject(cursor.error);
|
||||
});`);
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {win.document.getElementById('home').click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
|
||||
}
|
||||
const notificationMarker = `${root}/logs/test-notifications`;
|
||||
if (await IOUtils.exists(notificationMarker)) {
|
||||
await IOUtils.remove(notificationMarker);
|
||||
const result = {bootId:report.bootId,passed:false,silent:true};
|
||||
let id;
|
||||
try {
|
||||
await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms', 'return typeof Notification.get === "function";');
|
||||
await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
|
||||
result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
|
||||
await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
|
||||
id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
|
||||
const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
|
||||
n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
|
||||
});`);
|
||||
const selector = `[data-notification-id="${id}"]`;
|
||||
result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
|
||||
result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
|
||||
await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
|
||||
result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
|
||||
result.passed = result.tray && result.lockscreen && result.removed;
|
||||
} catch(error) { result.error=String(error); }
|
||||
finally {
|
||||
if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
|
||||
win.document.getElementById('home').click();
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
|
||||
}
|
||||
}
|
||||
await requestedUiChecks();
|
||||
let uiCheckRunning=false;
|
||||
const uiCheckTimer=setInterval(async()=>{
|
||||
if(uiCheckRunning) return;
|
||||
uiCheckRunning=true;
|
||||
try {
|
||||
const reloadMarker=`${root}/logs/test-reload-gaia`;
|
||||
if(await IOUtils.exists(reloadMarker)) {
|
||||
await IOUtils.remove(reloadMarker);
|
||||
win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
|
||||
await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
|
||||
await sleep(1500);
|
||||
}
|
||||
await requestedUiChecks();
|
||||
} catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
|
||||
finally {uiCheckRunning=false;}
|
||||
},3000);
|
||||
win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
|
||||
report.layout=await evaluate('homescreen',"return {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,rootFont:getComputedStyle(document.documentElement).fontSize,bodyFont:getComputedStyle(document.body).fontFamily,zoom:visualViewport.scale,rootWidth:document.documentElement.scrollWidth};");
|
||||
report.textFonts = await evaluate('homescreen', `
|
||||
await document.fonts.ready;
|
||||
|
|
|
|||
22
tundra/manifests/sargo-hardware.json
Normal file
22
tundra/manifests/sargo-hardware.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"device": "sargo",
|
||||
"scope": "Pinned hardware image inputs for local standalone builds; redistribution not reviewed",
|
||||
"images": {
|
||||
"vendor.img": {
|
||||
"source": "vendor_a.img",
|
||||
"sha256": "d584429873c88db741b81144b55977bbd8126b3e877445901bee6a0bda7fe4d0",
|
||||
"bytes": 805306368
|
||||
},
|
||||
"modem.img": {
|
||||
"source": "modem_a.img",
|
||||
"sha256": "fd0624b37ac1d80c064a9763c7e2d2d6f94c3fd5e4617965b735ad411a473d0a",
|
||||
"bytes": 115343360
|
||||
}
|
||||
},
|
||||
"dtbo": {
|
||||
"sha256": "0e43a57d6423e8d24cd944fcad0939ed81160e847a6d4203e2f7c16a86d69c03",
|
||||
"bytes": 8417280
|
||||
},
|
||||
"vendorFingerprint": "google/sargo/sargo:9/PD2A.190115.029/5320088:user/release-keys"
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage
|
|||
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
|
||||
|
||||
|
||||
HARDWARE_SERVICES = frozenset(('vendor.qcrild', 'vendor.google.radioext@1.0',
|
||||
HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'vendor.google.radioext@1.0',
|
||||
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
|
||||
'camera_service', 'minimedia', 'minisf', 'miniaf'))
|
||||
|
||||
|
|
|
|||
38
tundra/runtime/mark-boot-success.py
Normal file
38
tundra/runtime/mark-boot-success.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Acknowledge slot A only after this standalone installation has started Gaia."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
def eligible(boot_id, gaia, mount, cmdline, services_ready):
|
||||
return (cmdline.get('androidboot.slot_suffix') == '_a'
|
||||
and mount.get('source') == '/dev/mmcblk0p72'
|
||||
and mount.get('fstype') == 'ext4'
|
||||
and gaia.get('bootId') == boot_id and gaia.get('passed') is True
|
||||
and bool(gaia.get('checks')) and all(gaia['checks'].values())
|
||||
and services_ready)
|
||||
|
||||
|
||||
def main():
|
||||
cmdline=dict(x.split('=',1) for x in Path('/proc/cmdline').read_text().split() if '=' in x)
|
||||
boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
||||
mounts=json.loads(subprocess.check_output(['findmnt','--json','--mountpoint','/reference/tundra-volume','--output','SOURCE,FSTYPE'],text=True))['filesystems']
|
||||
if len(mounts)!=1 or mounts[0]['source']!='/dev/mmcblk0p72':
|
||||
return # Container/RAM experiments must not mark a flashed slot successful.
|
||||
for _ in range(120):
|
||||
try:
|
||||
gaia=json.loads(Path('/opt/vulpes/logs/test.json').read_text())
|
||||
ready=subprocess.run(['systemctl','is-active','--quiet','tundra-hal','tundra-display','tundra-radio']).returncode==0
|
||||
if eligible(boot_id,gaia,mounts[0],cmdline,ready):break
|
||||
except (OSError,ValueError):pass
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Gaia did not pass startup; slot success left unchanged')
|
||||
subprocess.run(['/usr/libexec/tundra-qbootctl','-m','a'],check=True)
|
||||
Path('/var/lib/tundra').mkdir(parents=True,exist_ok=True)
|
||||
Path('/var/lib/tundra/boot-success.json').write_text(json.dumps({'bootId':boot_id,'gaiaPassed':True,'slot':'a'})+'\n')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
34
tundra/runtime/prepare-bluetooth.py
Normal file
34
tundra/runtime/prepare-bluetooth.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Connect the Sargo Bluetooth HAL to BlueZ; pairing stays user initiated."""
|
||||
|
||||
def prepare(root, write):
|
||||
write('etc/dbus-1/system.d/tundra-bluetooth.conf', '''<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig><policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy></busconfig>
|
||||
''')
|
||||
write('etc/bluetooth/main.conf', '[General]\nName=Vulpes OS\nDiscoverableTimeout=120\n[Policy]\nAutoEnable=false\n')
|
||||
write('etc/systemd/system/tundra-bluebinder.service', '''[Unit]
|
||||
Description=Sargo Bluetooth HAL bridge
|
||||
Requires=tundra-hal.service
|
||||
After=tundra-hal.service
|
||||
Before=bluetooth.service
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStartPre=/usr/bin/binder-wait android.hardware.bluetooth@1.0::IBluetoothHci/default
|
||||
ExecStart=/usr/sbin/bluebinder
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStartSec=60
|
||||
NoNewPrivileges=yes
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=yes
|
||||
''')
|
||||
write('etc/systemd/system/bluetooth.service.d/tundra.conf', '''[Unit]
|
||||
Requires=tundra-bluebinder.service dbus.service
|
||||
After=tundra-bluebinder.service dbus.service
|
||||
''')
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
wants.mkdir(parents=True, exist_ok=True)
|
||||
(wants/'bluetooth.service').symlink_to('/usr/lib/systemd/system/bluetooth.service')
|
||||
|
|
@ -3,10 +3,13 @@
|
|||
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import runpy
|
||||
|
||||
|
||||
def prepare(root, write):
|
||||
write('etc/tundra/hardware-enabled', 'sargo\n')
|
||||
runpy.run_path(str(Path(__file__).with_name('runtime-packages.py')))['stage'](root)
|
||||
runpy.run_path(str(Path(__file__).with_name('prepare-bluetooth.py')))['prepare'](root, write)
|
||||
shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
|
||||
root/'etc/tundra/modem-recovery.py')
|
||||
write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]
|
||||
|
|
|
|||
82
tundra/runtime/runtime-packages.py
Normal file
82
tundra/runtime/runtime-packages.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Stage pinned package payloads missing from the original ARM64 reference."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
PACKAGES = (
|
||||
('libdw1t64_0.196-1_arm64.deb', 'e/elfutils', 'a82634454de2b28d73374f693e4ab6c1c2634fa53b0139312c5f439c294dad2b'),
|
||||
('libelf1t64_0.196-1_arm64.deb', 'e/elfutils', 'ead8a1244824d982ce51fb82be4904312d5dc44ddc157e589def58c257738476'),
|
||||
('bluez_5.87-4_arm64.deb', 'b/bluez', 'fe733034dbdfa6146fc94112ad8ca0fce71d8e01d22a59ddb49ef2df3777d6b5'),
|
||||
('ca-certificates_20260816_all.deb', 'c/ca-certificates', '1cb8b73c74c47677b329fad4095fb9370227d4b8f1969372411478a4586c46cf'),
|
||||
)
|
||||
|
||||
|
||||
# Existing libelf 0.194-4 files in the inventoried runtime; reject other replacements.
|
||||
REPLACES = {'usr/lib/aarch64-linux-gnu/libelf-0.194.so': 'ade23632ea253cb4ecfe95d5436bfeb3d40ae2003c5fe10bf1ac1c5b30bdf2e2', 'usr/share/doc/libelf1t64/changelog.Debian.gz': 'ffb0ea69fdbf6f32053059985048676931fd7d5c4458f8ca4bc54750cf4b1b31', 'usr/share/doc/libelf1t64/changelog.gz': '2e8e0af5c39ab744ac6934fe07419b5fd5504a50cd2f42110dd04315c548a708', 'usr/share/doc/libelf1t64/copyright': '2409860c86675ab33f8ad22480608faf849cbc2559d6478f708c23c508a1dbf2', 'usr/share/locale/de/LC_MESSAGES/elfutils.mo': 'f72328e130e63f231acb8e1a56ce5f4f3e91d0fc290db95d7ba4e4b2448d4d93', 'usr/share/locale/en@boldquot/LC_MESSAGES/elfutils.mo': '90158a24e3f85aaacc64085ef2644ba19075cb847c754341c9f245aaa69e94c6', 'usr/share/locale/en@quot/LC_MESSAGES/elfutils.mo': '33c6cda39c2145463dfdb2eb849922dbf6ccf51a9e4bfa8fd757744712d9abfa', 'usr/share/locale/es/LC_MESSAGES/elfutils.mo': '1ebff7e5583759b3f08eb5caaf18dd1a6ed6eb5ec71a02502ed8e21b134ad54f', 'usr/share/locale/ja/LC_MESSAGES/elfutils.mo': '764ed859f3aebd861da0b73c69da4d72d0f252952bb3be1c9c66360258a1661c', 'usr/share/locale/pl/LC_MESSAGES/elfutils.mo': '8abb000f43024994a9ec3032b52eb10858e004b81bc61c7ceea96f7467d13fdd', 'usr/share/locale/uk/LC_MESSAGES/elfutils.mo': 'ce0d747f478ee91a1cdef66bfd1e230a3ff1e787358f4c6e1ffb6513df713117'}
|
||||
|
||||
def stage(root, cache=None):
|
||||
cache = cache or Path(__file__).resolve().parents[1]/'downloads/native-packages-20261008'
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
records = []
|
||||
for name, directory, sha in PACKAGES:
|
||||
url = 'https://deb.debian.org/debian/pool/main/'+directory+'/'+name
|
||||
package = cache/name
|
||||
if not package.exists():
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
data = response.read()
|
||||
if hashlib.sha256(data).hexdigest() != sha:
|
||||
raise ValueError('Package checksum mismatch: '+name)
|
||||
package.write_bytes(data)
|
||||
if hashlib.sha256(package.read_bytes()).hexdigest() != sha:
|
||||
raise ValueError('Package checksum mismatch: '+name)
|
||||
members = subprocess.check_output(['ar', 't', str(package)], text=True).splitlines()
|
||||
member = next(m for m in members if m.startswith('data.tar'))
|
||||
payload = subprocess.check_output(['ar', 'p', str(package), member])
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
source = Path(folder)
|
||||
with tarfile.open(fileobj=io.BytesIO(payload)) as archive:
|
||||
archive.extractall(source, filter='data')
|
||||
if name.startswith('ca-certificates'):
|
||||
certificates = sorted((source/'usr/share/ca-certificates/mozilla').glob('*.crt'))
|
||||
if len(certificates) < 50:
|
||||
raise ValueError('Incomplete CA bundle')
|
||||
target = root/'etc/ssl/certs/ca-certificates.crt'
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b'\n'.join(p.read_bytes() for p in certificates)+b'\n')
|
||||
target.chmod(0o644)
|
||||
cert_link = root/'usr/lib/ssl/cert.pem'
|
||||
cert_link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cert_link.exists() and not cert_link.is_symlink():
|
||||
cert_link.symlink_to('../../../etc/ssl/certs/ca-certificates.crt')
|
||||
selected = [source/'usr/share/doc/ca-certificates/copyright']
|
||||
else:
|
||||
selected = [p for p in (source/'usr').rglob('*') if p.is_file() or p.is_symlink()]
|
||||
for path in selected:
|
||||
target = root/path.relative_to(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_symlink():
|
||||
if target.exists() or target.is_symlink():
|
||||
if not target.is_symlink() or target.readlink() != path.readlink():
|
||||
raise ValueError('Conflicting package link: '+str(target))
|
||||
else:
|
||||
target.symlink_to(path.readlink())
|
||||
else:
|
||||
if target.exists() and target.read_bytes() != path.read_bytes():
|
||||
expected = REPLACES.get(str(path.relative_to(source)))
|
||||
if not expected or hashlib.sha256(target.read_bytes()).hexdigest() != expected:
|
||||
raise ValueError('Conflicting package file: '+str(target))
|
||||
shutil.copyfile(path, target)
|
||||
target.chmod(path.stat().st_mode & 0o777)
|
||||
records.append({'file': name, 'url': url, 'sha256': sha})
|
||||
record = root/'usr/share/vulpes/runtime-packages.json'
|
||||
record.parent.mkdir(parents=True, exist_ok=True)
|
||||
record.write_text(json.dumps(records, indent=2)+'\n')
|
||||
return records
|
||||
|
|
@ -53,7 +53,19 @@ def main():
|
|||
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
|
||||
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed')
|
||||
run('mount','-t','ext4','-o','loop,ro,noload',str(image),'/run/tundra/android-original')
|
||||
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
|
||||
hardware = Path('/etc/tundra/hardware-images.json')
|
||||
bundled = json.loads(hardware.read_text())['images'] if hardware.exists() else None
|
||||
if bundled:
|
||||
if set(bundled) != {'vendor.img', 'modem.img'}:
|
||||
raise RuntimeError('Expected both standalone hardware images')
|
||||
for name, record in bundled.items():
|
||||
path = Path('/reference')/name
|
||||
with path.open('rb') as source:
|
||||
if path.stat().st_size != record['bytes'] or hashlib.file_digest(source, 'sha256').hexdigest() != record['sha256']:
|
||||
raise RuntimeError('Standalone hardware image changed: '+name)
|
||||
run('mount','-t','ext4','-o','loop,ro,noload','/reference/vendor.img','/run/tundra/vendor-original')
|
||||
else:
|
||||
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
|
||||
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
|
||||
overlay('/run/tundra/android-original','/android','android')
|
||||
overlay('/run/tundra/vendor-original','/android/vendor','vendor')
|
||||
|
|
@ -65,7 +77,10 @@ def main():
|
|||
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
|
||||
config.write_text(text) # vendor overlay upper is tmpfs, never the partition
|
||||
directory('/android/vendor/firmware_mnt')
|
||||
run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
|
||||
if bundled:
|
||||
run('mount','-t','vfat','-o','loop,ro','/reference/modem.img','/android/vendor/firmware_mnt')
|
||||
else:
|
||||
run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
|
||||
report={}
|
||||
for p in sorted(Path('/android').rglob('*.rc')):
|
||||
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ class HardwareTests(unittest.TestCase):
|
|||
self.assertIn('unmanaged-devices=*,except:interface-name:wlan0', config)
|
||||
self.assertIn('auth-polkit=root-only', config)
|
||||
self.assertIn('enabled=false', config)
|
||||
self.assertFalse((root/'etc/NetworkManager/system-connections').exists())
|
||||
connections = root/'etc/NetworkManager/system-connections'
|
||||
self.assertTrue(connections.is_dir())
|
||||
self.assertEqual(list(connections.iterdir()), [])
|
||||
wifi_service = (root/'etc/systemd/system/tundra-wifi.service').read_text()
|
||||
self.assertIn('ReadWritePaths=/run /etc/NetworkManager/system-connections', wifi_service)
|
||||
self.assertIn('VULPES_TUNDRA=1', (root/'etc/systemd/system/tundra-display.service').read_text())
|
||||
|
||||
|
||||
|
|
|
|||
47
tundra/tests/test_runtime_packages.py
Normal file
47
tundra/tests/test_runtime_packages.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Check the pinned Bluetooth libraries and native TLS trust store offline."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('packages', ROOT/'runtime/runtime-packages.py')
|
||||
packages = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(packages)
|
||||
CACHE = ROOT/'downloads/native-packages-20261008'
|
||||
|
||||
|
||||
class RuntimePackages(unittest.TestCase):
|
||||
def test_corrupt_archive_is_rejected_before_extraction(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
base = Path(folder)
|
||||
cache = base/'cache'
|
||||
cache.mkdir()
|
||||
(cache/packages.PACKAGES[0][0]).write_bytes(b'corrupt')
|
||||
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
|
||||
packages.stage(base/'root', cache)
|
||||
self.assertFalse((base/'root').exists())
|
||||
|
||||
@unittest.skipUnless(all((CACHE/name).exists() for name, _, _ in packages.PACKAGES),
|
||||
'Pinned package cache is not installed')
|
||||
def test_staged_payload_is_repeatable_and_certificates_are_usable(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
first = packages.stage(root, CACHE)
|
||||
self.assertEqual(packages.stage(root, CACHE), first)
|
||||
self.assertEqual(json.loads((root/'usr/share/vulpes/runtime-packages.json').read_text()), first)
|
||||
self.assertTrue((root/'usr/libexec/bluetooth/bluetoothd').is_file())
|
||||
context = ssl.create_default_context(cafile=str(root/'etc/ssl/certs/ca-certificates.crt'))
|
||||
self.assertGreater(context.cert_store_stats()['x509_ca'], 50)
|
||||
self.assertTrue((root/'usr/lib/ssl/cert.pem').is_file())
|
||||
target = root/'usr/libexec/bluetooth/bluetoothd'
|
||||
target.write_bytes(b'unknown local binary')
|
||||
with self.assertRaisesRegex(ValueError, 'Conflicting package file'):
|
||||
packages.stage(root, CACHE)
|
||||
self.assertEqual(target.read_bytes(), b'unknown local binary')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
37
tundra/tests/test_sparse_backup.py
Normal file
37
tundra/tests/test_sparse_backup.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import io
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
from sparse_backup import receive
|
||||
|
||||
|
||||
def record(offset,data):return struct.pack('<QQ',offset,len(data))+data
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def test_holes_and_final_size_are_restored(self):
|
||||
stream=b'TUNRAW1\0'+record(2,b'abc')+record(9,b'x')+record(12,b'')
|
||||
target=io.BytesIO();receive(io.BytesIO(stream),target,12)
|
||||
# BytesIO does not extend on truncate; actual disk files do.
|
||||
self.assertEqual(target.getvalue(),b'\0\0abc\0\0\0\0x')
|
||||
|
||||
def test_empty_disk_has_a_required_trailer(self):
|
||||
receive(io.BytesIO(b'TUNRAW1\0'+record(100,b'')),io.BytesIO(),100)
|
||||
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'),io.BytesIO(),100)
|
||||
|
||||
def test_overlapping_or_out_of_bounds_blocks_rejected(self):
|
||||
for records in (record(2,b'abc')+record(4,b'bad'),record(8,b'too long')):
|
||||
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'+records),io.BytesIO(),10)
|
||||
|
||||
def test_wrong_trailer_and_trailing_bytes_rejected(self):
|
||||
for stream in (b'TUNRAW1\0'+record(9,b''),b'TUNRAW1\0'+record(10,b'')+b'extra'):
|
||||
with self.assertRaises(ValueError):receive(io.BytesIO(stream),io.BytesIO(),10)
|
||||
|
||||
def test_truncated_payload_rejected(self):
|
||||
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'+struct.pack('<QQ',0,5)+b'four'),io.BytesIO(),10)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
91
tundra/tests/test_standalone_boot.py
Normal file
91
tundra/tests/test_standalone_boot.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
TOOLS=Path(__file__).resolve().parents[1]/'tools'
|
||||
sys.path.insert(0,str(TOOLS))
|
||||
from standalone_volume import checked_volume
|
||||
spec=importlib.util.spec_from_file_location('standalone_boot',TOOLS/'build-standalone-boot.py')
|
||||
boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
|
||||
|
||||
|
||||
class StandaloneBootTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.volume={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':'userdata.ext4',
|
||||
'uuid':'12345678-1234-1234-1234-123456789abc','bytes':8*1024**3,
|
||||
'imageSha256':'a'*64,'systemImageSha256':'b'*64,'filesystemCheckPassed':True}
|
||||
self.init=b'''EXPECTED_STORAGE_HASH=old
|
||||
# Use the inventoried filesystem
|
||||
mount /dev/mapper/tundra-installed-ro /installed
|
||||
echo TUNDRA_RESCUE_READY
|
||||
exec /bin/sh /native-root.sh
|
||||
'''
|
||||
self.native=b'''SYSTEM_IMAGE=/installed/tundra/images/system.ext4
|
||||
LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root
|
||||
mkdir -p /newroot/reference/installed
|
||||
mount --move /installed /newroot/reference/installed
|
||||
'''
|
||||
|
||||
def test_direct_boot_has_no_legacy_storage_path(self):
|
||||
init,native,mount=boot.storage_scripts(self.init,self.native,self.volume,False)
|
||||
self.assertNotIn(b'EXPECTED_STORAGE_HASH',init)
|
||||
self.assertNotIn(b'/dev/mapper',init)
|
||||
self.assertNotIn(b'/installed',native)
|
||||
self.assertNotIn(b'/home/droidian',native)
|
||||
self.assertIn(b'/dev/mmcblk0p72',mount)
|
||||
self.assertIn(b'VOLUME_OPTIONS=rw',mount)
|
||||
self.assertNotIn(b'mkfs',mount)
|
||||
self.assertIn(b'/newroot/reference/tundra-volume',native)
|
||||
|
||||
def test_container_boot_changes_only_volume_access(self):
|
||||
init,native,mount=boot.storage_scripts(self.init,self.native,self.volume,True)
|
||||
self.assertIn(b'/run/legacy-storage',init)
|
||||
self.assertIn(b'VOLUME_OPTIONS=loop,rw',mount)
|
||||
self.assertNotIn(b'/dev/mmcblk0p72',mount)
|
||||
self.assertIn(b'/volume/tundra/images',native)
|
||||
for script in (init,native,mount):
|
||||
self.assertEqual(subprocess.run(['sh','-n'],input=script,capture_output=True).returncode,0)
|
||||
|
||||
def test_bundled_hardware_is_bound_read_only(self):
|
||||
volume={**self.volume,'hardwareImages':{'vendor.img':{'sha256':'a'*64,'bytes':4096},'modem.img':{'sha256':'b'*64,'bytes':4096}}}
|
||||
native=self.native+b'# Installed lower layers stay reachable\n'
|
||||
_,script,_=boot.storage_scripts(self.init,native,volume,False)
|
||||
self.assertIn(b'mount -o remount,bind,ro /newroot/reference/vendor.img',script)
|
||||
self.assertIn(b'/newroot/etc/tundra/hardware-images.json',script)
|
||||
self.assertEqual(subprocess.run(['sh','-n'],input=script,capture_output=True).returncode,0)
|
||||
|
||||
def test_wrong_volume_metadata_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'volume.json'
|
||||
path.write_text(json.dumps(self.volume));checked_volume(path,False)
|
||||
for key,value in [('device','bonito'),('image','../userdata.ext4'),('bytes',1),('uuid','$(reboot)'),('storage','lvm'),('filesystemCheckPassed',False)]:
|
||||
path.write_text(json.dumps({**self.volume,key:value}))
|
||||
with self.assertRaises(ValueError):checked_volume(path,False)
|
||||
|
||||
def test_boot_success_requires_current_gaia_on_raw_userdata(self):
|
||||
spec=importlib.util.spec_from_file_location('success',TOOLS.parent/'runtime/mark-boot-success.py')
|
||||
success=importlib.util.module_from_spec(spec);spec.loader.exec_module(success)
|
||||
gaia={'bootId':'current','passed':True,'checks':{'home':True}}
|
||||
mount={'source':'/dev/mmcblk0p72','fstype':'ext4'}
|
||||
self.assertTrue(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_a'},True))
|
||||
self.assertFalse(success.eligible('new',gaia,mount,{'androidboot.slot_suffix':'_a'},True))
|
||||
self.assertFalse(success.eligible('current',gaia,{'source':'/dev/loop1','fstype':'ext4'},{'androidboot.slot_suffix':'_a'},True))
|
||||
self.assertFalse(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_b'},True))
|
||||
self.assertFalse(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_a'},False))
|
||||
|
||||
def test_old_installer_refuses_standalone_image(self):
|
||||
spec=importlib.util.spec_from_file_location('install',TOOLS/'install-sargo.py')
|
||||
install=importlib.util.module_from_spec(spec);spec.loader.exec_module(install)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory);(path/'build.json').write_text(json.dumps({'storageMode':'raw-userdata-ext4'}))
|
||||
with self.assertRaisesRegex(ValueError,'standalone installer'):
|
||||
install.checked_build(path)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
80
tundra/tests/test_standalone_flash.py
Normal file
80
tundra/tests/test_standalone_flash.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('flash',Path(__file__).resolve().parents[1]/'tools/flash-standalone-sargo.py')
|
||||
flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
|
||||
|
||||
|
||||
class FlashTests(unittest.TestCase):
|
||||
def plan(self):
|
||||
return {'userdataBytes':8*1024**3,'partitions':{name:{'bytes':4096} for name in flash.PARTITIONS}}
|
||||
|
||||
def values(self,**changes):
|
||||
values={'product':'sargo','unlocked':'yes','current-slot':'b',
|
||||
**{'partition-size:'+name:hex(64*1024**3 if name=='userdata' else 64*1024**2) for name in flash.PARTITIONS}}
|
||||
values.update(changes);return values
|
||||
|
||||
def test_both_slots_accepted_for_full_install(self):
|
||||
for slot in ('a','b'):
|
||||
values=self.values(**{'current-slot':slot})
|
||||
with patch.object(flash,'getvar',side_effect=lambda _,__,name:values[name]):
|
||||
self.assertEqual(flash.preflight('fastboot','serial',self.plan())['current-slot'],slot)
|
||||
|
||||
def test_wrong_model_locked_or_small_partition_rejected(self):
|
||||
for change in ({'product':'bonito'},{'unlocked':'no'},{'partition-size:userdata':'0x1000'}):
|
||||
values=self.values(**change)
|
||||
with patch.object(flash,'getvar',side_effect=lambda _,__,name:values[name]):
|
||||
with self.assertRaises(ValueError):flash.preflight('fastboot','serial',self.plan())
|
||||
|
||||
def test_review_never_contacts_device(self):
|
||||
with patch.object(sys,'argv',['flash']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run,patch.object(flash.subprocess,'check_output') as read:
|
||||
flash.main();run.assert_not_called();read.assert_not_called()
|
||||
|
||||
def test_install_requires_explicit_userdata_replacement(self):
|
||||
with patch.object(sys,'argv',['flash','--install']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run:
|
||||
with self.assertRaises(SystemExit):flash.main()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_unqualified_package_requires_experimental_flag(self):
|
||||
with patch.object(sys,'argv',['flash','--install','--erase-userdata']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run:
|
||||
with self.assertRaises(SystemExit):flash.main()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_sparse_geometry(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
image=Path(directory)/'userdata.img'
|
||||
image.write_bytes(struct.pack('<I4H4I',0xed26ff3a,1,0,28,12,4096,2097152,3,0))
|
||||
self.assertEqual(flash.sparse_size(image),8*1024**3)
|
||||
image.write_bytes(b'bad')
|
||||
with self.assertRaises(ValueError):flash.sparse_size(image)
|
||||
|
||||
def test_incomplete_package_is_rejected_before_manifest_read(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
(Path(directory)/'.incomplete').touch()
|
||||
with self.assertRaisesRegex(ValueError,'construction is incomplete'):
|
||||
flash.package(directory)
|
||||
|
||||
def test_install_writes_only_planned_partitions_after_preflight(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
argv=['flash','--package',directory,'--install','--erase-userdata','--experimental']
|
||||
with patch.object(sys,'argv',argv),patch.object(flash,'package',return_value=self.plan()), \
|
||||
patch.object(flash.shutil,'which',return_value='fastboot'), \
|
||||
patch.object(flash.subprocess,'check_output',return_value='test-device fastboot\n'), \
|
||||
patch.object(flash,'preflight',return_value={'product':'sargo'}) as preflight, \
|
||||
patch.object(flash.subprocess,'run') as run:
|
||||
flash.main()
|
||||
preflight.assert_called_once()
|
||||
commands=[call.args[0][3:] for call in run.call_args_list]
|
||||
self.assertEqual(commands[0],['erase','userdata'])
|
||||
self.assertEqual([command[:2] for command in commands[1:-1]],
|
||||
[['flash',partition] for partition in flash.PARTITIONS])
|
||||
self.assertEqual(commands[-1],['set_active','a'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
69
tundra/tests/test_standalone_restore.py
Normal file
69
tundra/tests/test_standalone_restore.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('restore', Path(__file__).resolve().parents[1]/'tools/restore-standalone-sargo.py')
|
||||
restore = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(restore)
|
||||
|
||||
|
||||
class RestoreTests(unittest.TestCase):
|
||||
def make_backup(self, folder):
|
||||
payload = b'original userdata\0' * 100
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
data = {'archive': 'userdata-original.raw.gz', 'bytes': len(payload),
|
||||
'sha256': sha, 'checked': True, 'sourceWasUnmounted': True}
|
||||
(folder/data['archive']).write_bytes(gzip.compress(payload))
|
||||
(folder/'userdata.json').write_text(json.dumps(data))
|
||||
files = {}
|
||||
for name in ('boot_a.img', 'dtbo_a.img', 'vbmeta_a.img'):
|
||||
content = name.encode()
|
||||
(folder/name).write_bytes(content)
|
||||
files[name] = {'bytes': len(content), 'sha256': hashlib.sha256(content).hexdigest()}
|
||||
manifest = {'device': 'sargo', 'activeSlot': 'a', 'files': files,
|
||||
'userdataSha256': sha, 'userdataBytes': len(payload)}
|
||||
(folder/'recovery.json').write_text(json.dumps(manifest))
|
||||
return manifest
|
||||
|
||||
def test_review_verifies_backup_without_phone_commands(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
folder = Path(directory)
|
||||
self.make_backup(folder)
|
||||
with patch.object(sys, 'argv', ['restore', '--backup', directory]), \
|
||||
patch.object(restore.subprocess, 'run') as run, \
|
||||
patch.object(restore.subprocess, 'check_output') as read:
|
||||
restore.main()
|
||||
run.assert_not_called()
|
||||
read.assert_not_called()
|
||||
|
||||
def test_unbound_userdata_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
folder = Path(directory)
|
||||
manifest = self.make_backup(folder)
|
||||
manifest['userdataSha256'] = '0' * 64
|
||||
(folder/'recovery.json').write_text(json.dumps(manifest))
|
||||
with patch.object(sys, 'argv', ['restore', '--backup', directory]), \
|
||||
patch.object(restore.subprocess, 'run') as run:
|
||||
with self.assertRaisesRegex(ValueError, 'not bound'):
|
||||
restore.main()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_altered_userdata_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
folder = Path(directory)
|
||||
self.make_backup(folder)
|
||||
(folder/'userdata-original.raw.gz').write_bytes(gzip.compress(b'changed'))
|
||||
with patch.object(sys, 'argv', ['restore', '--backup', directory]):
|
||||
with self.assertRaisesRegex(ValueError, 'Storage backup changed'):
|
||||
restore.main()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -184,9 +184,19 @@ def main():
|
|||
buffer = io.BytesIO()
|
||||
selected = [*sorted((ROOT.parent/'host').rglob('*')),
|
||||
*sorted((ROOT.parent/'services').rglob('*.mjs')),
|
||||
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
|
||||
ROOT.parent/'tools/installed_apps.py',
|
||||
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
|
||||
*sorted((ROOT.parent/'overrides').rglob('*')),
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
|
||||
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
|
||||
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
|
||||
ROOT.parent/'tools/project.py',
|
||||
ROOT.parent/'tools/serve-gaia.py',
|
||||
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
|
||||
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
|
||||
|
|
|
|||
146
tundra/tools/build-standalone-boot.py
Normal file
146
tundra/tools/build-standalone-boot.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Build a raw-userdata Tundra boot, or its nondestructive container test variant."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from ramdisk_reference import read_newc
|
||||
from standalone_volume import checked_volume
|
||||
from tundra import ROOT, checked_source, cpio, digest
|
||||
|
||||
|
||||
def storage_scripts(init, native, volume, trial):
|
||||
start = init.index(b'# Use the inventoried filesystem')
|
||||
end = init.index(b'echo TUNDRA_RESCUE_READY', start)
|
||||
legacy = init[start:end]
|
||||
# The container test keeps the old mount solely as a carrier for the image.
|
||||
if trial:
|
||||
legacy += b'mkdir -p /run/legacy-storage\nmount --move /installed /run/legacy-storage\n'
|
||||
else:
|
||||
legacy = b'# Standalone boot does not open the previous operating system.\n'
|
||||
init = init[:start]+legacy+init[end:]
|
||||
init = init.replace(b'exec /bin/sh /native-root.sh', b'exec /bin/sh /standalone-mount.sh')
|
||||
native = native.replace(b'LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root',
|
||||
b': "${SYSTEM_IMAGE:?A bundled system image is required}"\nLOWER=/run/tundra-system')
|
||||
native = native.replace(b'/installed', b'/volume')
|
||||
native = native.replace(b'/reference/volume', b'/reference/tundra-volume')
|
||||
if volume.get('hardwareImages'):
|
||||
lines = ['mkdir -p /newroot/reference']
|
||||
for name in ('vendor.img', 'modem.img'):
|
||||
lines += ['test -s /volume/tundra/hardware/'+name,
|
||||
': > /newroot/reference/'+name,
|
||||
'mount --bind /volume/tundra/hardware/'+name+' /newroot/reference/'+name,
|
||||
'mount -o remount,bind,ro /newroot/reference/'+name]
|
||||
config = json.dumps({'images':volume['hardwareImages']}, separators=(',',':'))
|
||||
lines += ['mkdir -p /newroot/etc/tundra',
|
||||
'printf \'%s\\n\' '+shlex.quote(config)+' > /newroot/etc/tundra/hardware-images.json']
|
||||
native = native.replace(b'# Installed lower layers', ('\n'.join(lines)+'\n# Installed lower layers').encode())
|
||||
if not trial:
|
||||
if b'/dev/mapper' in init or b'EXPECTED_STORAGE_HASH' in init:
|
||||
# The old expected hash assignment is unnecessary in the raw image.
|
||||
init = b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'EXPECTED_STORAGE_HASH='))
|
||||
if b'/dev/mapper' in init or b'/installed/' in native:
|
||||
raise ValueError('Legacy storage leaked into standalone boot')
|
||||
device = '/run/legacy-storage/tundra/standalone/'+volume['uuid']+'.ext4' if trial else '/dev/mmcblk0p72'
|
||||
config = 'VOLUME_DEVICE='+shlex.quote(device)+'\nVOLUME_UUID='+shlex.quote(volume['uuid'])+'\nVOLUME_OPTIONS='+('loop,rw' if trial else 'rw')+'\n'
|
||||
mount = (ROOT/'boot/standalone-mount.sh').read_bytes().replace(b'# VOLUME_CONFIGURATION', config.encode())
|
||||
return init, native, mount
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--volume', type=Path, required=True)
|
||||
parser.add_argument('--prepared', type=Path, required=True)
|
||||
parser.add_argument('--device-workspace', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--container-trial', action='store_true')
|
||||
parser.add_argument('--no-deadline', action='store_true')
|
||||
parser.add_argument('--boot-control', type=Path, help='Pinned ARM64 qbootctl binary')
|
||||
parser.add_argument('--no-maintenance', action='store_true', help='Exclude private USB SSH credentials and server')
|
||||
parser.add_argument('--local-debugger', action='store_true', help='Private RAM tests: loopback Marionette, never a public image')
|
||||
args = parser.parse_args(); os.umask(0o077)
|
||||
if args.local_debugger and args.no_maintenance: parser.error('Local debugger requires a private maintenance image')
|
||||
volume = checked_volume(args.volume)
|
||||
if args.no_deadline and not args.container_trial and not args.boot_control:
|
||||
parser.error('Persistent standalone images require --boot-control')
|
||||
if args.boot_control and digest(args.boot_control) != 'dcb00e4c4f038071a5d9979263128325e16b848903371f6c00d49cf3e25c34d9':
|
||||
raise ValueError('Unqualified boot-control binary')
|
||||
if args.boot_control and digest(args.boot_control.with_name('copyright')) != '8969e659aff7ed5bb3249341764c2bd048d5abe7400076f01485650e727e8c42':
|
||||
raise ValueError('Boot-control license notice missing or changed')
|
||||
system = json.loads((args.prepared/'system.json').read_text())
|
||||
state = json.loads((args.prepared/'data/state.json').read_text())
|
||||
if volume['systemImageSha256'] != system['imageSha256'] or volume['stateUuid'] != state['uuid']:
|
||||
raise ValueError('Volume does not contain this system/data pair')
|
||||
command = [sys.executable,str(ROOT/'tools/build-rescue-boot.py'), '--output',str(args.output),
|
||||
'--device-workspace',str(args.device_workspace),'--layout',str(args.device_workspace/'layout.json'),
|
||||
'--native','--runtime-config','--network','--hardware',
|
||||
'--system-image',str(args.prepared/'system.json'),'--state',str(args.prepared/'data/state.json')]
|
||||
if args.no_deadline: command.append('--no-deadline')
|
||||
if args.local_debugger: command.append('--local-debugger')
|
||||
subprocess.run(command,check=True)
|
||||
out = args.output.resolve(); build = json.loads((out/'build.json').read_text())
|
||||
ramdisk = out/'initramfs.cpio.gz'; files = read_newc(ramdisk.read_bytes())
|
||||
init,native,mount = storage_scripts(files['init']['data'],files['native-root.sh']['data'],volume,args.container_trial)
|
||||
if args.boot_control:
|
||||
files['sbin/tundra-qbootctl']={'mode':stat.S_IFREG|0o755,'data':args.boot_control.read_bytes()}
|
||||
files['qbootctl-copyright']={'mode':stat.S_IFREG|0o644,'data':args.boot_control.with_name('copyright').read_bytes()}
|
||||
files['standalone-boot-success.py']={'mode':stat.S_IFREG|0o755,'data':(ROOT/'runtime/mark-boot-success.py').read_bytes()}
|
||||
unit=b'[Unit]\nDescription=Acknowledge a healthy standalone Vulpes boot\nAfter=tundra-display.service\n[Service]\nType=oneshot\nTimeoutStartSec=650\nExecStart=/usr/bin/python3 /usr/libexec/tundra-mark-boot-success.py\n'
|
||||
files['standalone-boot-success.service']={'mode':stat.S_IFREG|0o644,'data':unit}
|
||||
setup=b"""mkdir -p /newroot/usr/libexec /newroot/etc/systemd/system/tundra.target.wants
|
||||
mkdir -p /newroot/usr/share/doc/qbootctl
|
||||
cp /qbootctl-copyright /newroot/usr/share/doc/qbootctl/copyright
|
||||
cp /sbin/tundra-qbootctl /newroot/usr/libexec/tundra-qbootctl
|
||||
cp /standalone-boot-success.py /newroot/usr/libexec/tundra-mark-boot-success.py
|
||||
cp /standalone-boot-success.service /newroot/etc/systemd/system/tundra-boot-success.service
|
||||
ln -sf ../tundra-boot-success.service /newroot/etc/systemd/system/tundra.target.wants/tundra-boot-success.service
|
||||
"""
|
||||
native=native.replace(b'# Installed lower layers',setup+b'# Installed lower layers')
|
||||
if args.no_maintenance:
|
||||
init=b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'/sbin/dropbear '))
|
||||
init=init.replace(b'TUNDRA_USB_SSH_READY',b'TUNDRA_USB_READY')
|
||||
start=native.index(b'# Keep a minimal, already tested rescue environment')
|
||||
end=native.index(b'for fs in dev proc sys run;',start)
|
||||
native=native[:start]+b'# Public boot: no USB maintenance account or shared SSH key.\n'+native[end:]
|
||||
for name in ('etc/tundra-hostkey','root/.ssh/authorized_keys','sbin/dropbear'):
|
||||
files.pop(name,None)
|
||||
files['init']['data']=init; files['native-root.sh']['data']=native
|
||||
files['standalone-mount.sh']={'mode':stat.S_IFREG|0o755,'data':mount}
|
||||
files['volume']={'mode':stat.S_IFDIR|0o755,'data':b''}
|
||||
entries=[]
|
||||
for name, item in sorted(files.items()):
|
||||
entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
|
||||
ramdisk.write_bytes(cpio(entries))
|
||||
packager=checked_source('aosp-mkbootimg'); original=out/build['image']
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
unpack=Path(temp)/'unpack'
|
||||
text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],text=True)
|
||||
options=shlex.split(text); fields=dict(zip(options[::2],options[1::2]));fields['--ramdisk']=str(ramdisk)
|
||||
image=out/'boot.img'; command=[sys.executable,str(packager/'mkbootimg.py')]
|
||||
for key,value in fields.items():command.extend([key,value])
|
||||
subprocess.run(command+['-o',str(image)],check=True)
|
||||
verify=Path(temp)/'verify'
|
||||
subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
|
||||
if digest(verify/'kernel')!=build['kernelSha256'] or digest(verify/'ramdisk')!=digest(ramdisk):
|
||||
raise ValueError('Standalone boot roundtrip failed')
|
||||
original.unlink()
|
||||
for path in (Path(__file__).resolve(),ROOT/'tools/standalone_volume.py',ROOT/'boot/standalone-mount.sh',ROOT/'runtime/mark-boot-success.py'):
|
||||
relative=str(path.relative_to(ROOT)); target=out/'sources/tundra'/relative
|
||||
target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target);build['sources'][relative]=digest(path)
|
||||
build.update(image='boot.img',imageSha256=digest(image),ramdiskSha256=digest(ramdisk),
|
||||
storageMode='container-test' if args.container_trial else 'raw-userdata-ext4',
|
||||
standaloneVolume=volume,flashReady=False,bootControlSha256=digest(args.boot_control) if args.boot_control else None,
|
||||
containsPrivateTrialHostKey=not args.no_maintenance,maintenanceEnabled=not args.no_maintenance,
|
||||
scope='Standalone Tundra boot candidate; hardware qualification required')
|
||||
(out/'build.json').write_text(json.dumps(build,indent=2)+'\n')
|
||||
print(image)
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
83
tundra/tools/build-standalone-volume.py
Normal file
83
tundra/tools/build-standalone-volume.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Build a fresh ext4 userdata image. No connection to a phone or partition writes."""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from persistent_state import FEATURES
|
||||
from tundra import digest
|
||||
from standalone_volume import checked_volume
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prepared', required=True, type=Path)
|
||||
parser.add_argument('--output', required=True, type=Path)
|
||||
parser.add_argument('--hardware', type=Path, help='Locally supplied pinned vendor/modem images')
|
||||
parser.add_argument('--size-mib', type=int, default=8192)
|
||||
args = parser.parse_args()
|
||||
if not 5120 <= args.size_mib <= 32768:
|
||||
parser.error('Choose 5120–32768 MiB')
|
||||
spec = importlib.util.spec_from_file_location('stage', Path(__file__).with_name('stage-sargo-files.py'))
|
||||
stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
|
||||
files = stage.inputs(args.prepared)
|
||||
hardware = {}
|
||||
if args.hardware:
|
||||
lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
|
||||
hardware = lock['images']
|
||||
for name, record in hardware.items():
|
||||
source = args.hardware/record['source']
|
||||
if source.is_symlink() or source.stat().st_size != record['bytes'] or digest(source) != record['sha256']:
|
||||
raise ValueError('Hardware image changed: '+name)
|
||||
payload_bytes = sum(p.stat().st_size for _,_,p,_ in files)
|
||||
payload_bytes += sum(record['bytes'] for record in hardware.values())
|
||||
if args.size_mib*1024**2 < payload_bytes+512*1024**2:
|
||||
raise ValueError('Insufficient space for payload and filesystem metadata')
|
||||
os.umask(0o077)
|
||||
out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False)
|
||||
(out/'.incomplete').touch()
|
||||
seed = out/'seed'; seed.mkdir()
|
||||
for kind, name, source, record in files:
|
||||
target = seed/'tundra'/kind/name; target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# mke2fs reads the file; a hard link avoids another multi-gigabyte copy.
|
||||
os.link(source, target)
|
||||
if hardware:
|
||||
directory = seed/'tundra/hardware'; directory.mkdir()
|
||||
for name, record in hardware.items():
|
||||
os.link(args.hardware/record['source'], directory/name)
|
||||
system = json.loads((args.prepared/'system.json').read_text())
|
||||
state = json.loads((args.prepared/'data/state.json').read_text())
|
||||
identifier = str(uuid.uuid4())
|
||||
(seed/'tundra-volume-id').write_text(identifier+'\n')
|
||||
image = out/'userdata.ext4'
|
||||
with image.open('xb') as f: f.truncate(args.size_mib*1024**2)
|
||||
subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
|
||||
'-U',identifier,'-L','TUNDRA_VOLUME','-d',str(seed),str(image)],check=True)
|
||||
names=['/tundra-volume-id','/tundra','/tundra/images','/tundra/data']
|
||||
names += ['/tundra/'+kind+'/'+name for kind,name,_,_ in files]
|
||||
if hardware:
|
||||
names += ['/tundra/hardware', *['/tundra/hardware/'+name for name in hardware]]
|
||||
for name in names:
|
||||
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)
|
||||
result = subprocess.run(['e2fsck','-f','-n',str(image)],capture_output=True,text=True)
|
||||
(out/'filesystem-check.txt').write_text(result.stdout+result.stderr)
|
||||
if result.returncode: raise ValueError('Standalone filesystem check failed')
|
||||
manifest={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':image.name,
|
||||
'uuid':identifier,'bytes':image.stat().st_size,'imageSha256':digest(image),
|
||||
'systemImageSha256':system['imageSha256'],'stateUuid':state['uuid'],
|
||||
'filesystemCheckPassed':True,'flashReady':False,'hardwareImages':hardware,
|
||||
'scope':'Fresh standalone storage; flashing userdata destroys previous user data'}
|
||||
(out/'volume.json').write_text(json.dumps(manifest,indent=2)+'\n')
|
||||
checked_volume(out/'volume.json')
|
||||
(out/'.incomplete').unlink()
|
||||
print(out/'volume.json')
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
|
|
@ -128,7 +128,7 @@ def main():
|
|||
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
|
||||
for name in ('tools', 'tests', 'logs', 'profiles'):
|
||||
(product/name).mkdir(exist_ok=True)
|
||||
for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
|
||||
for name in ('serve-gaia.py', 'project.py', 'run-host.py', 'installed_apps.py'):
|
||||
shutil.copy2(PROJECT/'tools'/name, product/'tools'/name)
|
||||
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
|
||||
shutil.copy2(PROJECT/'release.json', product/'release.json')
|
||||
|
|
|
|||
116
tundra/tools/flash-standalone-sargo.py
Normal file
116
tundra/tools/flash-standalone-sargo.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Install a standalone Sargo package using Fastboot only (Python 3, any host OS).
|
||||
|
||||
Default: verify files and display the destructive installation plan.
|
||||
--install --erase-userdata replaces the old OS and ALL files in userdata.
|
||||
Keep a separate backup. The unlocked bootloader and device calibration remain.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PARTITIONS=('userdata','dtbo_a','vbmeta_a','boot_a')
|
||||
NAMES={'userdata':'userdata.img','dtbo_a':'dtbo.img','vbmeta_a':'vbmeta.img','boot_a':'boot.img'}
|
||||
|
||||
|
||||
def digest(path):
|
||||
sha=hashlib.sha256()
|
||||
with path.open('rb') as source:
|
||||
while block:=source.read(1024*1024):sha.update(block)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def sparse_size(path):
|
||||
with path.open('rb') as source:header=source.read(28)
|
||||
if len(header)!=28:raise ValueError('Truncated userdata image')
|
||||
magic,major,minor,file_header,chunk_header,block_size,blocks,chunks,crc=struct.unpack('<I4H4I',header)
|
||||
if magic!=0xed26ff3a or major!=1 or file_header!=28 or chunk_header!=12 or block_size!=4096 or not chunks:
|
||||
raise ValueError('Expected an Android sparse ext4 image')
|
||||
return block_size*blocks
|
||||
|
||||
|
||||
def package(folder):
|
||||
folder=Path(folder).resolve()
|
||||
if (folder/'.incomplete').exists():raise ValueError('Package construction is incomplete')
|
||||
value=json.loads((folder/'package.json').read_text())
|
||||
if value.get('schema')!=1 or value.get('device')!='sargo' or value.get('storage')!='raw-userdata-ext4':
|
||||
raise ValueError('Wrong package model or storage format')
|
||||
if set(value.get('partitions',{}))!=set(PARTITIONS):raise ValueError('Unexpected partition write plan')
|
||||
for partition,record in value['partitions'].items():
|
||||
if record.get('file')!=NAMES[partition]:raise ValueError('Unexpected image filename')
|
||||
path=folder/record['file']
|
||||
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:
|
||||
raise ValueError('Image checksum or size mismatch: '+partition)
|
||||
if sparse_size(folder/'userdata.img')!=value['userdataBytes']:raise ValueError('Sparse userdata geometry changed')
|
||||
return value
|
||||
|
||||
|
||||
def getvar(fastboot,serial,name):
|
||||
result=subprocess.run([fastboot,'-s',serial,'getvar',name],capture_output=True,text=True,check=True,timeout=15)
|
||||
for line in (result.stdout+'\n'+result.stderr).splitlines():
|
||||
match=re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)',line.strip())
|
||||
if match:return match[1].strip()
|
||||
raise ValueError('Missing Fastboot variable: '+name)
|
||||
|
||||
|
||||
def preflight(fastboot,serial,value):
|
||||
info={name:getvar(fastboot,serial,name) for name in ('product','unlocked','current-slot')}
|
||||
if info['product']!='sargo' or info['unlocked']!='yes' or info['current-slot'] not in ('a','b'):
|
||||
raise ValueError('Requires an unlocked Pixel 3a (sargo), not Pixel 3a XL')
|
||||
for partition in PARTITIONS:
|
||||
size=int(getvar(fastboot,serial,'partition-size:'+partition),16)
|
||||
needed=value['userdataBytes'] if partition=='userdata' else value['partitions'][partition]['bytes']
|
||||
if needed>size:raise ValueError('Image exceeds partition: '+partition)
|
||||
return info
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--package',type=Path,default=Path(__file__).resolve().parent)
|
||||
parser.add_argument('--serial')
|
||||
parser.add_argument('--install',action='store_true')
|
||||
parser.add_argument('--erase-userdata',action='store_true')
|
||||
parser.add_argument('--experimental',action='store_true')
|
||||
parser.add_argument('--reboot',action='store_true')
|
||||
args=parser.parse_args();value=package(args.package)
|
||||
print('Pixel 3a / Sargo: replace userdata, dtbo_a, vbmeta_a and boot_a; activate slot A.')
|
||||
print('ALL existing user data and the old OS in userdata will be lost. Bootloader remains unlocked.')
|
||||
print('No existing Android or Droidian installation is required.')
|
||||
if not args.install:
|
||||
print('Review only; no device command sent.');return
|
||||
if not args.erase_userdata:parser.error('--install requires --erase-userdata')
|
||||
if not value.get('flashQualified') and not args.experimental:
|
||||
parser.error('This candidate is not qualified; private tests require --experimental')
|
||||
fastboot=shutil.which('fastboot')
|
||||
if not fastboot:raise ValueError('Install Android platform-tools (fastboot) on the host computer')
|
||||
devices=subprocess.check_output([fastboot,'devices'],text=True,timeout=10).splitlines()
|
||||
serials=[line.split()[0] for line in devices if len(line.split())==2 and line.split()[1]=='fastboot']
|
||||
serial=args.serial
|
||||
if serial is None:
|
||||
if len(serials)!=1:raise ValueError('Connect exactly one Pixel or specify --serial')
|
||||
serial=serials[0]
|
||||
if serial not in serials:raise ValueError('Selected device not in Fastboot')
|
||||
info=preflight(fastboot,serial,value)
|
||||
report={'deviceSha256':hashlib.sha256(serial.encode()).hexdigest(),'preflight':info,'completed':[],'passed':False}
|
||||
path=args.package/'install-report.json'
|
||||
def save():path.write_text(json.dumps(report,indent=2)+'\n')
|
||||
save()
|
||||
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
|
||||
report['completed'].append('erase:userdata');save()
|
||||
for partition in PARTITIONS:
|
||||
subprocess.run([fastboot,'-s',serial,'flash',partition,str((args.package/NAMES[partition]).resolve())],check=True,timeout=1200)
|
||||
report['completed'].append(partition);save()
|
||||
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
|
||||
report['passed']=True;save()
|
||||
if args.reboot:subprocess.run([fastboot,'-s',serial,'reboot'],check=True,timeout=30)
|
||||
print('Images written. Keep the bootloader unlocked. Select Start to launch Vulpes.')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
|
|
@ -23,6 +23,8 @@ def load(name, path):
|
|||
def checked_build(folder):
|
||||
folder=Path(folder).resolve()
|
||||
build=json.loads((folder/'build.json').read_text())
|
||||
if build.get('storageMode') in ('raw-userdata-ext4', 'container-test'):
|
||||
raise ValueError('Use the standalone installer; the old boot-only plan is incompatible')
|
||||
if build.get('localDebugger'):raise ValueError('Diagnostic debugger image cannot be flashed')
|
||||
qualification=json.loads((folder/'qualification.json').read_text())
|
||||
if (not qualification.get('passed') or not qualification.get('privateInstallReady')
|
||||
|
|
|
|||
63
tundra/tools/package-standalone-sargo.py
Normal file
63
tundra/tools/package-standalone-sargo.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Package boot, fresh userdata and boot metadata for a standalone installation.
|
||||
|
||||
Builds local artifacts only. A candidate is never marked flash-qualified here.
|
||||
Private development credentials, if present in boot, prohibit redistribution.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from standalone_volume import checked_volume
|
||||
from tundra import ROOT,digest
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--build',required=True,type=Path)
|
||||
parser.add_argument('--volume',required=True,type=Path)
|
||||
parser.add_argument('--dtbo',required=True,type=Path)
|
||||
parser.add_argument('--output',required=True,type=Path)
|
||||
args=parser.parse_args();os.umask(0o077)
|
||||
volume=checked_volume(args.volume);build=json.loads((args.build/'build.json').read_text())
|
||||
if build.get('storageMode')!='raw-userdata-ext4' or build.get('watchdogSeconds') is not None or build.get('localDebugger'):
|
||||
raise ValueError('Expected a persistent standalone boot without debugger or timed reboot')
|
||||
if build['standaloneVolume']!=volume or not volume.get('hardwareImages'):
|
||||
raise ValueError('Boot must match a volume with bundled vendor/modem images')
|
||||
boot=args.build/build['image']
|
||||
if digest(boot)!=build['imageSha256']:raise ValueError('Boot image changed')
|
||||
spec=importlib.util.spec_from_file_location('sources',ROOT/'tools/test-rescue-boot.py')
|
||||
sources=importlib.util.module_from_spec(spec);spec.loader.exec_module(sources);sources.verify_sources(args.build,build)
|
||||
lock=json.loads((ROOT/'manifests/sargo-hardware.json').read_text())
|
||||
if args.dtbo.stat().st_size!=lock['dtbo']['bytes'] or digest(args.dtbo)!=lock['dtbo']['sha256']:
|
||||
raise ValueError('Unqualified device-tree overlay')
|
||||
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
|
||||
(out/'.incomplete').touch()
|
||||
shutil.copyfile(boot,out/'boot.img');shutil.copyfile(args.dtbo,out/'dtbo.img')
|
||||
subprocess.run(['avbtool','make_vbmeta_image','--flags','3','--padding_size','4096','--algorithm','NONE','--output',str(out/'vbmeta.img')],check=True)
|
||||
subprocess.run(['img2simg',str(args.volume.parent/volume['image']),str(out/'userdata.img')],check=True)
|
||||
# Roundtrip verifies the actual sparse payload, not only its header.
|
||||
restored=out/'userdata-roundtrip.ext4'
|
||||
subprocess.run(['simg2img',str(out/'userdata.img'),str(restored)],check=True)
|
||||
if digest(restored)!=volume['imageSha256']:raise ValueError('Sparse image roundtrip mismatch')
|
||||
restored.unlink()
|
||||
files={name:{'file':file,'bytes':(out/file).stat().st_size,'sha256':digest(out/file)}
|
||||
for name,file in [('boot_a','boot.img'),('userdata','userdata.img'),('dtbo_a','dtbo.img'),('vbmeta_a','vbmeta.img')]}
|
||||
value={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','partitions':files,
|
||||
'userdataBytes':volume['bytes'],'userdataRawSha256':volume['imageSha256'],
|
||||
'volumeUuid':volume['uuid'],'systemImageSha256':volume['systemImageSha256'],
|
||||
'flashQualified':False,'containsPrivateMaintenanceKey':bool(build.get('containsPrivateTrialHostKey')),
|
||||
'redistributionReady':False,'previousOsRequired':False,
|
||||
'requirements':['Unlocked Pixel 3a (sargo)','Original device calibration partitions retained'],
|
||||
'scope':'Experimental standalone installation candidate; userdata is replaced in full'}
|
||||
(out/'package.json').write_text(json.dumps(value,indent=2)+'\n')
|
||||
shutil.copyfile(ROOT/'tools/flash-standalone-sargo.py',out/'flash-standalone-sargo.py')
|
||||
(out/'SHA256SUMS').write_text(''.join(digest(p)+' '+p.name+'\n' for p in sorted(out.iterdir()) if p.is_file() and p.name not in ('.incomplete','SHA256SUMS')))
|
||||
(out/'.incomplete').unlink();print(out/'package.json')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
83
tundra/tools/restore-standalone-sargo.py
Normal file
83
tundra/tools/restore-standalone-sargo.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Restore a private full-storage backup after a standalone Sargo test.
|
||||
|
||||
Default: verify the archive and partition backups only. --restore overwrites
|
||||
userdata and the three slot-A boot partitions. Backups are device-specific.
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def digest(path):
|
||||
sha=hashlib.sha256()
|
||||
with path.open('rb') as source:
|
||||
while block:=source.read(4*1024**2):sha.update(block)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--backup',required=True,type=Path)
|
||||
parser.add_argument('--restore',action='store_true')
|
||||
args=parser.parse_args();folder=args.backup.resolve()
|
||||
manifest=json.loads((folder/'recovery.json').read_text())
|
||||
if manifest.get('device')!='sargo' or manifest.get('activeSlot')!='a':raise ValueError('Wrong recovery target')
|
||||
data=json.loads((folder/'userdata.json').read_text())
|
||||
if not data.get('checked') or data.get('archive')!='userdata-original.raw.gz':raise ValueError('Unverified userdata backup')
|
||||
if not data.get('sourceWasUnmounted') or manifest.get('userdataSha256')!=data['sha256'] or manifest.get('userdataBytes')!=data['bytes']:
|
||||
raise ValueError('Storage backup is not bound to this recovery kit')
|
||||
for name,record in manifest['files'].items():
|
||||
if name not in ('boot_a.img','dtbo_a.img','vbmeta_a.img'):raise ValueError('Unexpected recovery partition')
|
||||
path=folder/name
|
||||
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:raise ValueError('Partition backup changed: '+name)
|
||||
if set(manifest['files'])!={'boot_a.img','dtbo_a.img','vbmeta_a.img'}:raise ValueError('Incomplete boot backup set')
|
||||
archive=folder/data['archive'];sha=hashlib.sha256();count=0
|
||||
with gzip.open(archive,'rb') as source:
|
||||
while block:=source.read(4*1024**2):
|
||||
count+=len(block)
|
||||
if count>data['bytes']:raise ValueError('Oversized backup')
|
||||
sha.update(block)
|
||||
if count!=data['bytes'] or sha.hexdigest()!=data['sha256']:raise ValueError('Storage backup changed')
|
||||
print('Private boot and full userdata backups verified.')
|
||||
if not args.restore:
|
||||
print('No phone action. --restore replaces its current userdata.');return
|
||||
fastboot=shutil.which('fastboot')
|
||||
if not fastboot:raise ValueError('Fastboot is required')
|
||||
serials=[line.split()[0] for line in subprocess.check_output([fastboot,'devices'],text=True).splitlines() if len(line.split())==2]
|
||||
matches=[serial for serial in serials if hashlib.sha256(serial.encode()).hexdigest()==manifest['serialSha256']]
|
||||
if len(matches)!=1:raise ValueError('Original phone not present in Fastboot')
|
||||
serial=matches[0]
|
||||
# Use the standalone installer's strict Fastboot variable parser.
|
||||
import importlib.util
|
||||
spec=importlib.util.spec_from_file_location('flash',Path(__file__).with_name('flash-standalone-sargo.py'))
|
||||
flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
|
||||
if flash.getvar(fastboot,serial,'product')!='sargo' or flash.getvar(fastboot,serial,'unlocked')!='yes':raise ValueError('Wrong or locked phone')
|
||||
if int(flash.getvar(fastboot,serial,'partition-size:userdata'),16)!=data['bytes']:raise ValueError('Storage geometry changed')
|
||||
raw=folder/'userdata-restore.raw'
|
||||
if raw.exists():
|
||||
if raw.stat().st_size!=data['bytes'] or digest(raw)!=data['sha256']:raise ValueError('Existing restoration file changed')
|
||||
else:
|
||||
if shutil.disk_usage(folder).free<data['bytes']+1024**3:raise ValueError('Not enough host space to decompress recovery image')
|
||||
partial=folder/'userdata-restore.partial'
|
||||
with gzip.open(archive,'rb') as source,partial.open('xb') as target:
|
||||
while block:=source.read(4*1024**2):
|
||||
if not block.strip(b'\0'):target.seek(len(block),1)
|
||||
else:target.write(block)
|
||||
target.truncate(data['bytes'])
|
||||
if digest(partial)!=data['sha256']:raise ValueError('Decompressed backup changed')
|
||||
partial.rename(raw)
|
||||
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
|
||||
subprocess.run([fastboot,'-s',serial,'-S','256M','flash','userdata',str(raw)],check=True,timeout=7200)
|
||||
for name in ('dtbo_a.img','vbmeta_a.img','boot_a.img'):
|
||||
subprocess.run([fastboot,'-s',serial,'flash',name.removesuffix('.img'),str(folder/name)],check=True,timeout=120)
|
||||
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
|
||||
print('Original userdata and boot restored. Select Start; bootloader remains unlocked.')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
25
tundra/tools/sparse_backup.py
Normal file
25
tundra/tools/sparse_backup.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Decode the read-only TUNRAW1 stream; reject truncation and overlapping blocks."""
|
||||
import struct
|
||||
|
||||
|
||||
def exact(stream, length):
|
||||
data=bytearray()
|
||||
while len(data)<length:
|
||||
block=stream.read(length-len(data))
|
||||
if not block:raise ValueError('Truncated backup stream')
|
||||
data.extend(block)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def receive(stream, target, expected_size):
|
||||
if exact(stream,8)!=b'TUNRAW1\0':raise ValueError('Unexpected backup protocol')
|
||||
end=0
|
||||
while True:
|
||||
offset,length=struct.unpack('<QQ',exact(stream,16))
|
||||
if not length:
|
||||
if offset!=expected_size or stream.read(1):raise ValueError('Backup size or trailer mismatch')
|
||||
target.truncate(expected_size)
|
||||
return
|
||||
if offset<end or length>65536 or offset+length>expected_size:raise ValueError('Invalid backup extent')
|
||||
target.seek(offset);target.write(exact(stream,length));end=offset+length
|
||||
35
tundra/tools/standalone_volume.py
Normal file
35
tundra/tools/standalone_volume.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Validate the fresh userdata filesystem used by standalone Sargo images."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import uuid
|
||||
from tundra import digest
|
||||
|
||||
|
||||
def checked_volume(path, verify_image=True):
|
||||
path = Path(path)
|
||||
value = json.loads(path.read_text())
|
||||
if (value.get('schema') != 1 or value.get('device') != 'sargo'
|
||||
or value.get('storage') != 'raw-userdata-ext4'
|
||||
or value.get('image') != 'userdata.ext4'
|
||||
or value.get('filesystemCheckPassed') is not True):
|
||||
raise ValueError('Expected a checked standalone Sargo volume')
|
||||
identifier = value.get('uuid')
|
||||
if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
|
||||
raise ValueError('Invalid volume UUID')
|
||||
for key in ('imageSha256', 'systemImageSha256'):
|
||||
if not re.fullmatch(r'[a-f0-9]{64}', value.get(key, '')):
|
||||
raise ValueError('Invalid '+key)
|
||||
if type(value.get('bytes')) is not int or not 5*1024**3 <= value['bytes'] <= 32*1024**3:
|
||||
raise ValueError('Invalid standalone filesystem size')
|
||||
hardware = value.get('hardwareImages', {})
|
||||
if hardware:
|
||||
lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
|
||||
if hardware != lock['images']:
|
||||
raise ValueError('Unqualified standalone hardware images')
|
||||
if verify_image:
|
||||
image = path.parent/value['image']
|
||||
if image.is_symlink() or image.stat().st_size != value['bytes'] or digest(image) != value['imageSha256']:
|
||||
raise ValueError('Standalone volume changed')
|
||||
return value
|
||||
Loading…
Reference in a new issue