226 lines
20 KiB
JavaScript
226 lines
20 KiB
JavaScript
import {tundraRadio} from 'resource://vulpes/host/Tundra.sys.mjs';
|
|
import {Runtime} from 'resource://vulpes/host/Runtime.sys.mjs';
|
|
import {setTimeout} from 'resource://gre/modules/Timer.sys.mjs';
|
|
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
|
|
export function install() {
|
|
Services.obs.addObserver(function opened(win) {
|
|
win.addEventListener('load',()=>{
|
|
if(win.location.href==='chrome://vulpes/content/shell.xhtml') run(win).catch(Cu.reportError);
|
|
},{once:true});
|
|
},'chrome-document-global-created');
|
|
}
|
|
|
|
async function run(win) {
|
|
const report={engine:Services.appinfo.platformVersion,build:Services.appinfo.platformBuildID,started:Date.now()};
|
|
const save=()=>IOUtils.writeJSON('/work/gaia-audit.json',report);
|
|
try {
|
|
for(let i=0;i<600&&!win.hostReady;i++)await sleep(200);
|
|
if(!win.hostReady)throw Error('Host startup timeout');
|
|
ChromeUtils.registerWindowActor('VulpesProbe',{
|
|
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
|
|
allFrames:true,safeForUntrustedWebProcess:true,
|
|
});
|
|
const system=win.document.getElementById('system').browsingContext;
|
|
const find=(context,app)=>{
|
|
if(context.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return context;
|
|
for(const child of context.children){const result=find(child,app);if(result)return result;}
|
|
return null;
|
|
};
|
|
const evaluate=(app,script)=>find(system,app).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',script);
|
|
for(let i=0;i<300;i++) {
|
|
if(find(system,'homescreen') && await evaluate('homescreen',"return document.querySelectorAll('gaia-app-icon').length>0;"))break;
|
|
await sleep(200);
|
|
}
|
|
report.system=await evaluate('system','return {ready:!!window.applications?.ready,size:[innerWidth,innerHeight]};');
|
|
report.homescreen=await evaluate('homescreen',"return {icons:document.querySelectorAll('gaia-app-icon').length,size:[innerWidth,innerHeight],text:document.body.innerText.slice(0,300)};");
|
|
for(let i=0;i<60;i++) {
|
|
report.icons=await evaluate('homescreen',"return [...document.querySelectorAll('gaia-app-icon')].map(e=>({id:e.dataset.identifier,loaded:!!e._hasIcon,pending:!!e._image,images:[...(e._container?.querySelectorAll('img') || [])].map(i=>({width:i.naturalWidth,complete:i.complete}))}));");
|
|
report.logoHidden=await evaluate('system',"var e=document.getElementById('os-logo');return !e || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) || Number(getComputedStyle(e).opacity)<.05;");
|
|
if(report.logoHidden&&report.icons.length>=4&&report.icons.slice(0,4).every(icon=>icon.images.some(image=>image.complete&&image.width>0)))break;
|
|
await sleep(500);
|
|
}
|
|
report.homescreen.icons=report.icons.length;
|
|
report.compactUI=await evaluate('homescreen',"return {height:innerHeight,singlePanel:document.getElementById('panels').scrollWidth===document.getElementById('panels').clientWidth,indicatorsHidden:getComputedStyle(document.getElementById('page-indicator-header')).display==='none',pinnedPagesHidden:getComputedStyle(document.getElementById('pages-panel')).display==='none'};");
|
|
report.homeSearchHidden=await evaluate('system',"return getComputedStyle(document.querySelector('#homescreen > .chrome')).display==='none';");
|
|
report.layout=await evaluate('homescreen',"return {mode:document.documentElement.dataset.vulpesLayout,icons:[...document.querySelectorAll('gaia-app-icon')].slice(0,9).map(e=>({rect:e.getBoundingClientRect().toJSON(),image:e._container?.querySelector('img')?.getBoundingClientRect().toJSON()}))};");
|
|
report.hardware=await evaluate('system',"return await VulpesCompat.call('platform.snapshot',{});");
|
|
report.radioAPI=await evaluate('system',"return {slots:navigator.mozMobileConnections?.length || 0,battery:navigator.battery.level,signal:!!document.getElementById('statusbar-mobile-connection'),radioUnavailable:document.documentElement.hasAttribute('data-vulpes-radio-unavailable'),cards:navigator.mozIccManager.iccIds.map(id=>navigator.mozIccManager.getIccById(id).cardState),connections:[...navigator.mozMobileConnections].map(c=>({radioState:c.radioState,registered:c.voice.connected,type:c.voice.type,signal:c.voice.relSignalStrength}))};");
|
|
if(Services.env.get('VULPES_TUNDRA_RADIO')==='1') {
|
|
for(let attempt=0;attempt<30;attempt++) {
|
|
report.phoneBroker=await tundraRadio('status');
|
|
if(report.phoneBroker.ready) {
|
|
try {report.currentCallCount=(await tundraRadio('calls')).length;break;} catch (_) {}
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
if(report.currentCallCount===undefined)throw Error('Phone broker did not become ready');
|
|
try {report.callAudio=await tundraRadio('callAudio',{settings:{operation:'status'}});}
|
|
catch(error) {report.callAudio={error:error.message};}
|
|
try { await tundraRadio('sms',{number:'INVALID',body:'validation only'});report.invalidSmsRejected=false; }
|
|
catch(error) { report.invalidSmsRejected=error.message==='INVALID_NUMBER'; }
|
|
}
|
|
report.readyMs=Date.now()-report.started;
|
|
const image=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
|
|
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
|
|
canvas.width=image.width;canvas.height=image.height;canvas.getContext('2d').drawImage(image,0,0);image.close();
|
|
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
|
|
await IOUtils.write('/work/gaia-native.png',new Uint8Array(await blob.arrayBuffer()));
|
|
await save();
|
|
if(Services.env.get("VULPES_TRIAL_MANUAL")==="1") {
|
|
let last='';
|
|
while(!win.closed) {
|
|
try {
|
|
const request=await IOUtils.readJSON('/work/manual-request.json');
|
|
if(typeof request.id==='string' && request.id!==last) {
|
|
last=request.id;
|
|
let response;
|
|
try {
|
|
if(request.app==='screenshot') {
|
|
const picture=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
|
|
canvas.getContext('2d').drawImage(picture,0,0);picture.close();
|
|
const capture=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
|
|
await IOUtils.write('/work/manual-screen.png',new Uint8Array(await capture.arrayBuffer()));
|
|
response={id:last,value:true};
|
|
} else response={id:last,value:await Promise.race([
|
|
evaluate(request.app,request.script),
|
|
sleep(30000).then(()=>{throw Error('Manual probe timed out');})
|
|
])};
|
|
} catch(error) {response={id:last,error:String(error)};}
|
|
await IOUtils.writeJSON('/work/manual-response.json',response,{tmpPath:'/work/manual-response.tmp'});
|
|
}
|
|
} catch (_) {}
|
|
await sleep(200);
|
|
}
|
|
return;
|
|
}
|
|
report.animation=await evaluate('homescreen',`return new Promise(resolve=>{let frames=0;const begin=performance.now();function tick(now){frames++;if(now-begin>=3000)resolve({frames,seconds:(now-begin)/1000,fps:frames*1000/(now-begin)});else requestAnimationFrame(tick);}requestAnimationFrame(tick);});`);
|
|
const until=async read=>{let value;for(let i=0;i<40;i++){value=await read();if(value)return value;await sleep(250);}return value;};
|
|
await evaluate('homescreen',"var first=document.querySelector('gaia-app-icon');first.setAttribute('tabindex','-1');first.focus();return document.activeElement===first;");
|
|
let sequence=0;
|
|
const key=async key=>{
|
|
const sent=++sequence;
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:sent,key},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
for(let i=0;i<40;i++) {
|
|
try {if((await IOUtils.readJSON('/work/keypad-response.json')).sequence===sent)return;} catch (_) {}
|
|
await sleep(100);
|
|
}
|
|
throw Error('Keypad acknowledgement timed out');
|
|
};
|
|
report.inputPath='kernel evdev → keypad reader → XTest → GTK → host → Gaia';
|
|
await key('ArrowRight');
|
|
report.keypadSelection=await until(()=>evaluate('homescreen',"var id=document.querySelector('[data-vulpes-keypad-focus]')?.dataset.identifier;return id?.includes('sms.localhost') ? id : null;"));
|
|
if(!report.keypadSelection)throw Error('Right did not select Messages');
|
|
report.selectedAppName=await until(()=>evaluate('homescreen',"return document.getElementById('vulpes-compact-app-name')?.textContent==='Messages';"));
|
|
await key('Enter');
|
|
for(let i=0;i<150&&!find(system,'sms');i++)await sleep(200);
|
|
report.smsOpened=!!find(system,'sms');
|
|
if(report.smsOpened) {
|
|
await until(()=>evaluate('sms',"return document.readyState==='complete' && !!document.body;"));
|
|
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('sms.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
|
|
await evaluate('sms',"var input=document.createElement('input');input.id='vulpes-keypad-probe';input.type='tel';input.style='position:fixed;top:80px;left:0;z-index:99999';document.body.append(input);input.focus();return true;");
|
|
await key('2');
|
|
report.keypadInput=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value;"));
|
|
await evaluate('sms',"var input=document.getElementById('vulpes-keypad-probe');input.value='';input.type='text';input.focus();return true;");
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
report.multitap=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bob';"));
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#2#2'},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
report.inputModes=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA2';"));
|
|
await key('Backspace');
|
|
report.deleteText=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA';"));
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#'},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-mode')?.textContent.startsWith('abc');"));
|
|
await evaluate('sms',"document.getElementById('vulpes-keypad-probe').remove();document.body.focus();return true;");
|
|
// Use the original SMS composer as well as the isolated editor probe.
|
|
await evaluate('sms',"var button=document.getElementById('threads-composer-link');button.focus();return true;");
|
|
await key('Enter');
|
|
if(!await until(()=>evaluate('sms',"return typeof ConversationView!=='undefined' && Navigation.isCurrentPanel('composer') && document.activeElement.classList.contains('recipient');")))throw Error('SMS composer did not open');
|
|
await evaluate('sms',"await App.whenReady();await new Promise(r=>setTimeout(r,400));ConversationView.recipients.focus();return true;");
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
if(!await until(()=>evaluate('sms',"return document.querySelector('#messages-recipients-list [contenteditable]')?.textContent==='5550100';")))throw Error('Recipient digits did not reach the original form');
|
|
await key('Enter');
|
|
report.smsRecipient=await until(()=>evaluate('sms',"return ConversationView.recipients.numbers.includes('5550100');"));
|
|
await evaluate('sms',"Compose.focus();return true;");
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
report.smsBody=await until(()=>evaluate('sms',"return document.getElementById('messages-input').textContent.trim()==='bob';"));
|
|
// Never send the synthetic message or retain it as a draft.
|
|
await evaluate('sms',"ConversationView.cleanFields();return true;");
|
|
await key('Escape');
|
|
await until(()=>evaluate('sms',"return !Navigation.isCurrentPanel('composer');"));
|
|
await key('Escape');
|
|
report.returnHome=await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('homescreen.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
|
|
}
|
|
if(report.returnHome) {
|
|
await evaluate('homescreen',"var icon=[...document.querySelectorAll('gaia-app-icon')].find(e=>e.dataset.identifier.includes('communications'));icon.focus();return true;");
|
|
await key('Enter');
|
|
report.dialerOpened=await until(()=>Promise.resolve(!!find(system,'communications')));
|
|
if(report.dialerOpened) {
|
|
if(!await until(()=>evaluate('communications',"return performance.getEntriesByName('contentInteractive').length>0 && document.getElementById('phone-number-view')?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});")))throw Error('Dialer keypad did not become interactive');
|
|
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('communications.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
|
|
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
|
|
report.dialerDigits=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='5550100';"));
|
|
report.dialerObserved=await evaluate('communications',"return document.getElementById('phone-number-view')?.value;");
|
|
for(let i=0;i<7;i++){await key('Backspace');await sleep(150);}
|
|
report.dialerClear=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='';"));
|
|
await key('Escape');
|
|
}
|
|
}
|
|
// Exercise the real Tundra worker with the exact 320x240 preview mode
|
|
// used by KaiOS on beatles. Keep the sample in memory and release Camera1
|
|
// immediately; this is a smoke check, not a retained photo or recording.
|
|
const cameraActor={};
|
|
let cameraSession;
|
|
try {
|
|
const metadata=await Runtime.platform.nativeCamera.request(cameraActor,{action:'start',camera:'back'});
|
|
cameraSession=metadata.session;
|
|
const frame=await Runtime.platform.nativeCamera.request(cameraActor,{action:'frame',session:cameraSession});
|
|
const data=new Uint8Array(frame.data);
|
|
const pixels=metadata.width*metadata.height;
|
|
let sum=0,min=255,max=0;
|
|
for(let i=0;i<Math.min(pixels,data.length);i++) { const value=data[i];sum+=value;min=Math.min(min,value);max=Math.max(max,value); }
|
|
report.cameraPreview={width:metadata.width,height:metadata.height,sequence:frame.sequence,
|
|
bytes:data.length,yMean:data.length?Number((sum/Math.min(pixels,data.length)).toFixed(2)):null,yMin:data.length?min:null,yMax:data.length?max:null,
|
|
passed:data.length===pixels*3/2 && max>32};
|
|
const picture=await Runtime.platform.nativeCamera.request(cameraActor,{action:'picture',session:cameraSession,size:{width:1600,height:1200}});
|
|
const jpeg=new Uint8Array(picture.data);
|
|
report.cameraPicture={bytes:jpeg.length,jpeg:jpeg[0]===0xff&&jpeg[1]===0xd8,passed:jpeg.length>4096&&jpeg[0]===0xff&&jpeg[1]===0xd8};
|
|
} catch(error) {
|
|
report.cameraPreview={passed:false,error:error.message || String(error)};
|
|
} finally {
|
|
if(cameraSession!==undefined) {
|
|
try { await Runtime.platform.nativeCamera.request(cameraActor,{action:'stop',session:cameraSession}); }
|
|
catch(error) { report.cameraPreview={...report.cameraPreview,stopError:error.message || String(error)}; }
|
|
}
|
|
}
|
|
report.cameraApps=Runtime.apps.filter(app=>/camera|photo/i.test(app.id+' '+app.name+' '+app.origin)).map(app=>({id:app.id,name:app.name,origin:app.origin,manifestURL:app.manifestURL}));
|
|
try {
|
|
// Match the URL-based launcher used by the other Gaia app audits. The
|
|
// app registry's internal ID is not stable across Gaia manifests.
|
|
await evaluate('system',`return await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});`);
|
|
for(let i=0;i<120&&!find(system,'camera');i++)await sleep(250);
|
|
if(find(system,'camera')) {
|
|
for(let i=0;i<120;i++) {
|
|
const state=await evaluate('camera',"return {ready:document.readyState==='complete',app:typeof app!=='undefined'&&!!app.camera,busy:typeof app!=='undefined'&&!!app.camera?.isBusy};");
|
|
if(state.ready&&state.app&&(!state.busy||i>=119))break;
|
|
await sleep(250);
|
|
}
|
|
await sleep(2500);
|
|
report.cameraApp=await evaluate('camera',"var v=document.querySelector('.js-video'),c=document.querySelector('canvas[data-vulpes-native-preview]'),a=typeof app==='undefined'?null:app,cam=a?.camera,stream=cam?.mozCamera,canvasLuma=null;try{if(c){const t=document.createElement('canvas');t.width=80;t.height=60;const x=t.getContext('2d',{willReadFrequently:true});x.drawImage(c,0,0,80,60);const p=x.getImageData(0,0,80,60).data;let sum=0,min=255,max=0;for(let i=0;i<p.length;i+=4){const y=(77*p[i]+150*p[i+1]+29*p[i+2])>>8;sum+=y;min=Math.min(min,y);max=Math.max(max,y);}canvasLuma={mean:Number((sum/4800).toFixed(1)),min,max};}}catch(e){canvasLuma={error:e.message||String(e)};}return {loaded:document.readyState==='complete',readyState:document.readyState,url:location.href,title:document.title,viewfinder:!!v,previewCanvas:!!c,canvasSize:c?[c.width,c.height]:null,canvasLuma,videoHidden:v?getComputedStyle(v).display==='none':false,documentHidden:document.hidden,visibility:document.visibilityState,errorText:document.querySelector('.overlay[data-type=\"request-fail\"] #overlay-text')?.textContent,bodyText:document.body?.innerText.slice(0,240),appGlobal:!!a,cameraObject:!!cam,cameraBusy:cam?.isBusy,cameraReady:!!stream&&!cam.isBusy,mozCamera:!!stream,previewFrames:stream?.vulpesTiming?.frames||0,previewClosed:stream?.vulpesClosed,mode:cam?.mode||null,cameraList:cam?.cameraList};");
|
|
if(report.cameraApp.cameraReady) {
|
|
try {
|
|
await evaluate('camera',"await app.camera.mozCamera.vulpesCameraControl({action:'flash',mode:'torch'});return true;");
|
|
await sleep(1500);
|
|
report.cameraApp.torchCanvas=await evaluate('camera',"const c=document.querySelector('canvas[data-vulpes-native-preview]'),t=document.createElement('canvas');t.width=80;t.height=60;const x=t.getContext('2d',{willReadFrequently:true});x.drawImage(c,0,0,80,60);const p=x.getImageData(0,0,80,60).data;let sum=0,min=255,max=0;for(let i=0;i<p.length;i+=4){const y=(77*p[i]+150*p[i+1]+29*p[i+2])>>8;sum+=y;min=Math.min(min,y);max=Math.max(max,y);}return {mean:Number((sum/4800).toFixed(1)),min,max};");
|
|
} catch(error) { report.cameraApp.torchCanvas={error:error.message||String(error)}; }
|
|
finally {
|
|
try { await evaluate('camera',"await app.camera.mozCamera.vulpesCameraControl({action:'flash',mode:'off'});return true;"); }
|
|
catch(error) { report.cameraApp.torchOffError=error.message||String(error); }
|
|
}
|
|
try { report.cameraApp.picture=await evaluate('camera',"const b=await app.camera.mozCamera.takePicture(),d=new Uint8Array(await b.arrayBuffer());let bitmap=null;try{bitmap=await createImageBitmap(b);const c=document.createElement('canvas');c.width=80;c.height=60;const x=c.getContext('2d',{willReadFrequently:true});x.drawImage(bitmap,0,0,c.width,c.height);const p=x.getImageData(0,0,c.width,c.height).data;let sum=0,min=255,max=0;for(let i=0;i<p.length;i+=4){const y=(77*p[i]+150*p[i+1]+29*p[i+2])>>8;sum+=y;min=Math.min(min,y);max=Math.max(max,y);}return {bytes:b.size,type:b.type,jpeg:d[0]===255&&d[1]===216,width:bitmap.width,height:bitmap.height,lumaMean:Number((sum/(p.length/4)).toFixed(1)),lumaMin:min,lumaMax:max};}finally{bitmap?.close();}"); }
|
|
catch(error) { report.cameraApp.picture={error:error.message||String(error)}; }
|
|
}
|
|
} else report.cameraApp={loaded:false,launch:'sent',apps:report.cameraApps};
|
|
} catch(error) { report.cameraApp={loaded:false,error:error.message||String(error),apps:report.cameraApps}; }
|
|
}catch(error){report.error=String(error);report.stack=error.stack;}
|
|
await save();
|
|
}
|