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;i32}; 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>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>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>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(); }