Release Vulpes OS 2.7-preview.22

This commit is contained in:
OverSu 2026-10-22 20:42:26 +02:00
commit a138f97877
294 changed files with 515786 additions and 187 deletions

View file

@ -0,0 +1,49 @@
// Explicit diagnostic: inspect real navigation and its rendered surfaces.
export async function auditBrowserVisual({win,evaluate,sleep,root}) {
const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const result={at:Date.now(),samples:[]};
const capture=async(context,name,rect=null)=>{
const image=await context.currentWindowGlobal.drawSnapshot(rect,1,'white');
const c=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
c.width=image.width;c.height=image.height;c.getContext('2d').drawImage(image,0,0);image.close();
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
await IOUtils.write(`${root}/logs/${name}.png`,new Uint8Array(await blob.arrayBuffer()));
};
try {
win.focus();win.document.getElementById('system').focus();
for(let n=0;n<40;n++) {
if(await evaluate('system','return !!window.core?.appCore?.rocketbar;')) break;
await sleep(500);
}
await evaluate('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1200);
await evaluate('system','const r=core.appCore.rocketbar;await r.activate();r.focus();return true;');
let keyboard=false;
for(let n=0;n<20;n++) {
keyboard=await evaluate('system','return !!document.getElementById("vulpes-gaia-keyboard")?.offsetHeight;');
if(keyboard) break;
await sleep(250);
}
if(!keyboard) throw Error('Keyboard did not open');
await sleep(1800);
await evaluate('system','const r=core.appCore.rocketbar;r.setInput("Vulpes OS");r.handleInput();return true;');
await sleep(700);
await evaluate('keyboard','navigator.mozInputMethod.inputcontext.sendKey(13,0).catch(console.error);return true;');
await sleep(8000);
for(let i=0;i<3;i++) {
await sleep(3000);
const sample={system:await evaluate('system','const r=core.appCore.rocketbar;return {active:Service.query("getActiveApp")?.origin,bar:r.active,input:r.input.value,port:!!r._port,keyboardHidden:document.getElementById("vulpes-gaia-keyboard")?.hidden,search:r.searchWindow?.iframe?.src};'),views:[]};
for(const [id,e] of Views.entries) {
const b=e.browser;
const v={id,url:b.currentURI.spec,loading:b.webProgress.isLoadingDocument,style:b.style.cssText,rect:b.getBoundingClientRect().toJSON(),contextId:b.browsingContext.id,geometry:e.geometry,active:b.docShellIsActive,renderLayers:b.renderLayers};
try {v.page=await b.browsingContext.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate','return {url:location.href,ready:document.readyState,text:document.body?.innerText.slice(0,1200),width:window.innerWidth,height:window.innerHeight,visibility:document.visibilityState};');await capture(b.browsingContext,`browser-page-${i}`);}catch(error){v.error=String(error);}
sample.views.push(v);
}
await capture(win.browsingContext,`browser-screen-${i}`,new win.DOMRect(0,0,win.innerWidth,win.innerHeight));
result.samples.push(sample);
await IOUtils.writeJSON(`${root}/logs/browser-visual.json`,result);
}
const last=result.samples.at(-1);result.passed=!!last.system.keyboardHidden && last.views.some(v=>v.style.includes('visibility: visible') && v.page?.text?.length>0 && v.page.width>0 && v.url.startsWith('https://duckduckgo.com/'));
}catch(error){result.error=String(error);}
await IOUtils.writeJSON(`${root}/logs/browser-visual.json`,result);
}

View file

@ -0,0 +1,69 @@
// Opt-in hardware regression checks; never run during a normal boot.
export async function auditMediaFeedback({win, evaluate, wait, tap, sleep, save}) {
const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const report={passed:false,checks:{},errors:{}};
const assert=(value,message)=>{if(!value)throw Error(message);};
const system=code=>evaluate('system','const rocketbar=core.appCore.rocketbar;'+code);
const principal=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI('http://camera.localhost:8765'),{});
const previous=Services.perms.testExactPermissionFromPrincipal(principal,'microphone');
try {
win.focus();win.document.getElementById("system").focus();
await system('ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await system('await rocketbar.activate();rocketbar.focus();return true;');
await wait('system','return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>0;');
await system('await VulpesCompat.call("input.request",{operation:"hide"});return true;');
await wait('system','return document.querySelector("#vulpes-gaia-keyboard").hidden;');
await system('rocketbar.focus();return true;');
report.checks.refocusKeyboard=!!await wait('system','return !document.querySelector("#vulpes-gaia-keyboard").hidden;');
await system('rocketbar.handleCancel();await VulpesCompat.call("input.request",{operation:"hide"});return true;');
await wait('system','return !core.appCore.rocketbar.active && !core.appCore.rocketbar.isClosing;');
await sleep(500);
await system('dispatchEvent(new CustomEvent("activity-view",{detail:{source:{data:{type:"url",url:"https://duckduckgo.com/?q=vulpes"}}}}));return true;');
await sleep(10000);
report.views=[...Views.entries.values()].map(e=>({url:e.browser.currentURI.spec,visible:e.browser.style.visibility,rect:e.browser.getBoundingClientRect().toJSON()}));
assert(report.views.some(v=>v.visible==='visible' && v.rect.height>0 && v.url.startsWith('https://duckduckgo.com/')),'DuckDuckGo view hidden');
report.checks.searchVisible=true;
const point=await system('const r=Service.query("getActiveApp").appChrome.title.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};');
await evaluate('system',point,'Tap');
report.checks.addressFirstTap=!!await wait('system','return !document.querySelector("#vulpes-gaia-keyboard").hidden && document.activeElement===core.appCore.rocketbar.input;');
win.document.getElementById('home').click();
await wait('system','return !core.appCore.rocketbar.active && !core.appCore.rocketbar.isClosing;');
win.document.getElementById('home').click();
await wait('system','return ["http://homescreen.localhost:8765","http://verticalhome.localhost:8765"].includes(Service.query("getActiveApp")?.origin);');
await sleep(1000);
assert([...Views.entries.values()].every(e=>e.browser.style.visibility==='hidden'),'External view covers Home');
report.checks.homeReturns=true;
} catch(error){report.errors.browser=String(error);}
await save(report);
try {
Services.perms.removeFromPrincipal(principal,'microphone');
await system('await VulpesCompat.call("apps.launch",{manifestURL:"http://camera.localhost:8765/manifest.webapp"});return true;');
await wait('camera','return !!window.app?.camera?.mozCamera && !app.camera.isBusy;');
await evaluate('camera','window.__lastVideo=null;app.once("newmedia",v=>{window.__lastVideo=v;});app.settings.mode.select("video");return true;');
await wait('camera','return app.camera.mode==="video" && !app.camera.isBusy;');
await sleep(500);
await tap('camera','.js-capture');
await wait('system','return document.getElementById("permission-screen").classList.contains("visible");',20000);
report.permission=await system('const p=document.getElementById("permission-screen"),r=document.getElementById("permission-yes").getBoundingClientRect();return {type:p.dataset.type,button:r.toJSON(),height:innerHeight};');
const bounds=win.document.getElementById('system').getBoundingClientRect();
assert(report.permission.button.bottom<=bounds.height+1,'Permission button extends under the Home bar');
await tap('system','#permission-yes');
report.checks.gaiaPermission=true;
await wait('camera','return app.camera.get("recording")==="started";',20000);
await sleep(3000);
await tap('camera','.js-capture');
await wait('camera','return !!window.__lastVideo?.blob;',30000);
report.video=await evaluate('camera',`const v=__lastVideo,el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);try{await Promise.race([new Promise((ok,no)=>{el.onloadeddata=ok;el.onerror=()=>no(Error('Decode failed'));}),new Promise((_,no)=>setTimeout(()=>no(Error('Decode timeout')),10000))]);return {native:!!app.camera.mozCamera.vulpesNative,bytes:v.blob.size,type:v.blob.type,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}finally{URL.revokeObjectURL(el.src);el.remove();}`);
assert(report.video.bytes>0 && report.video.width>0 && report.video.poster>0,'Invalid recording');
report.checks.videoDecoded=true;
} catch(error){report.errors.video=String(error);}
finally {
await system('if(document.getElementById("permission-screen").classList.contains("visible"))document.getElementById("permission-no").click();return true;').catch(()=>{});
await evaluate('camera','app.camera.stopRecording();await app.camera.mozCamera?.vulpesFinishRecording?.();if(window.__lastVideo){await navigator.getDeviceStorage("videos").delete(__lastVideo.filepath);await navigator.getDeviceStorage("pictures").delete(__lastVideo.poster.filepath);}app.settings.mode.select("picture");return true;').catch(()=>{});
if(previous===Ci.nsIPermissionManager.UNKNOWN_ACTION)Services.perms.removeFromPrincipal(principal,'microphone');
else Services.perms.addFromPrincipal(principal,'microphone',previous);
win.document.getElementById('home').click();
}
report.passed=Object.keys(report.errors).length===0;
await save(report);return report;
}

View file

@ -53,7 +53,7 @@ export class NativeCamera {
}
if (!this.session || this.session.actor !== actor || this.session.id !== params.session)
throw Error('CAMERA_NOT_OPEN');
if (!['frame','focus','zoom','picture'].includes(params.action)) throw Error('CAMERA_INVALID_COMMAND');
if (!['frame','focus','zoom','picture','flash'].includes(params.action)) throw Error('CAMERA_INVALID_COMMAND');
let controlRejected = false;
try {
if (params.action === 'frame') {

View file

@ -85,7 +85,7 @@ export class Platform {
if (Services.env.get('VULPES_TUNDRA_RADIO') === '1') {
const poll = async () => {
try { await this.radio('calls'); } catch (_) {}
setTimeout(poll, 500);
setTimeout(poll, Services.env.get('VULPES_DEVICE') === 'beatles' ? 1000 : 500);
};
poll();
const inbox = async()=>{
@ -103,7 +103,7 @@ export class Platform {
}
} catch(error) {console.error('SMS inbox:',error.message);}
try {
const rows=await tundraMms('incoming');
const rows=Services.env.get('VULPES_DEVICE') === 'beatles' ? [] : await tundraMms('incoming');
const app=this.runtime.apps.find(a=>a.id==='sms');
for(const row of rows) {
const message=await this.runtime.messages.receive(receivedMms(row));
@ -124,7 +124,13 @@ export class Platform {
if (operation === 'ackCallHistory') this.journal.acknowledge(params.ids);
else {
const result = await tundraRadio(operation, params);
if (operation === 'dial') this.journal.start({path:result.path,number:params.number,state:'dialing'}, Date.now(), 'outgoing');
if (operation === 'dial') {
const call = {path:result.path,number:params.number,state:'dialing',direction:'outgoing'};
this.journal.start(call, Date.now(), 'outgoing');
// The modem has confirmed this call; do not serve the pre-dial cache.
this.lastCalls = [...(this.lastCalls || []).filter(c=>c.path!==result.path),call];
this.lastCallsAt = Date.now();
}
if (operation === 'calls') {
const previous = new Set(this.journal.completed.map(c=>c.id));
this.journal.update(result); this.lastCalls = result; this.lastCallsAt = Date.now();
@ -175,16 +181,21 @@ export class Platform {
}
const principal = global.documentPrincipal;
const video = request.videoInputDevices?.[0], audio = request.audioInputDevices?.[0];
for (const [device, kind, message] of [
[video, 'camera', 'Autoriser Photo à utiliser la caméra ? / Allow Camera to use your camera?'],
[audio, 'microphone', 'Autoriser Photo à enregistrer le son ? / Allow Camera to record audio?']
for (const [device, kind] of [
[video, 'camera'],
[audio, 'microphone']
]) {
if (!device) continue;
let permission = Services.perms.testExactPermissionFromPrincipal(principal, kind);
if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
permission = Services.prompt.confirm(browser.ownerDocument.defaultView, 'Vulpes — Photo', message)
? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION;
Services.perms.addFromPrincipal(principal, kind, permission);
let decision;
try {
decision = await browser.browsingContext.currentWindowGlobal.getActor('Vulpes').sendQuery('Vulpes:MediaPermission', {
id:Services.uuid.generateUUID().toString(), origin:app.origin, manifestURL:app.manifestURL, kind,
});
} catch (_) { deny(); return; }
permission = decision?.allowed ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION;
if (decision?.remember) Services.perms.addFromPrincipal(principal, kind, permission);
}
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) { deny(); return; }
}
@ -230,6 +241,24 @@ export class Platform {
if (['settings', 'capture', 'alarms', 'notification'].includes(operation))
throw Error('NOT_SUPPORTED');
}
if (Services.env.get('VULPES_TUNDRA_STATUS') === '1') {
if (operation === 'wifi') return tundraWifi(params);
const radio = Services.env.get('VULPES_TUNDRA_RADIO') === '1';
if (radio && operation === 'radioPower') return tundraRadio('radioPower',params);
if (radio && operation === 'calls' && Date.now()-this.lastCallsAt<1500) return this.lastCalls;
if (radio && ['calls','dial','callHistory','ackCallHistory'].includes(operation)) return this.radio(operation,params);
if (radio && ['hangup','answer','sms'].includes(operation)) return tundraRadio(operation,params);
if (radio && operation === 'callAudio') return tundraCallAudio(params);
if (operation === 'snapshot') return tundraSnapshot();
if (operation === 'capabilities') return {
platform:'tundra', platformName:'Tundra', model:'Nokia 2720 Flip',
adapter:{version:'0.1.0-dev',device:'beatles'}, tundra:'0.1.0-dev',
version:this.runtime.release.version, gaia:this.runtime.release.gaia,
gecko:Services.appinfo.platformVersion, hardwareStatus:true,
radioStatus:true, wifiControl:Services.env.get('VULPES_TUNDRA_WIFI') === '1', camera:false, media:true, contacts:true,
notifications:true, dial:radio, callControl:radio, sms:radio, mms:false, callAudio:radio, alarms:false,
};
}
if (operation === 'capabilities')
return {
platform: 'desktop',

View file

@ -4,7 +4,7 @@ import {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
import { Packages } from 'resource://vulpes/services/packages.mjs';
import { Activities, isGaiaActivity } from 'resource://vulpes/services/activities.mjs';
import { SettingsService } from 'resource://vulpes/services/settings.mjs';
import { SettingsService, migrateSearchDefaults, migratePrivacyDefaults } from 'resource://vulpes/services/settings.mjs';
import { DataStoreService } from 'resource://vulpes/services/datastores.mjs';
import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs';
import { Views } from 'resource://vulpes/host/Views.sys.mjs';
@ -16,6 +16,7 @@ import { Platform } from 'resource://vulpes/host/Platform.sys.mjs';
import { Media } from 'resource://vulpes/host/Media.sys.mjs';
import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs';
Cu.importGlobalProperties(['indexedDB']);
const beatlesHardware = () => Services.env.get('VULPES_DEVICE') === 'beatles' && Services.env.get('VULPES_TUNDRA_RADIO') === '1';
// This is the only privileged owner of application identity and settings data.
// Application messages are never allowed to supply their own permissions.
@ -46,6 +47,7 @@ export const Runtime = {
this.idleListeners.delete(actor);
},
async init() {
this.hardwareKeyboard = Services.env.get('VULPES_KEYPAD') === '1';
this.input = new Input(this);
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json'));
@ -106,6 +108,12 @@ export const Runtime = {
'deviceinfo.platform_version': Services.appinfo.platformVersion,
'deviceinfo.platform_build_id': Services.appinfo.platformBuildID,
};
const privacyChanged = migratePrivacyDefaults(this.values);
if (migrateSearchDefaults(this.values) || privacyChanged) {
await IOUtils.writeJSON(path, this.values, {tmpPath:path+'.tmp'});
}
for (const pref of ['privacy.trackingprotection.enabled', 'privacy.donottrackheader.enabled'])
Services.prefs.setBoolPref(pref, this.values[pref] === true);
if (!saved['vulpes.gaia-restoration.v1']) {
this.values['lockscreen.enabled'] = true;
this.values['vulpes.gaia-restoration.v1'] = true;
@ -117,6 +125,15 @@ export const Runtime = {
notify:(actor,type,data)=>actor.sendAsyncMessage('Vulpes:Event',{type,data}),
launch:(target,path,{reload=false,entryPoint}={})=>this.broadcast(reload?'activity-launch':'launch',{manifestURL:target.manifestURL,origin:target.origin,
entryPoint,url:new URL(path || target.manifest.launch_path || '/index.html',target.origin).href,timestamp:Date.now()},'system')});
if (beatlesHardware()) {
try {
const volumes = await tundraRadio('volumeState');
for (const [stream,level] of Object.entries(volumes)) {
if (!['content','notification','alarm','telephony','bt_sco'].includes(stream) || !Number.isFinite(level) || level<0 || level>1) continue;
this.values['audio.volume.'+stream]=Math.round(level*(['telephony','bt_sco'].includes(stream)?5:15));
}
} catch (error) { console.warn('Nokia volume state:',error.message); }
}
this.writeQueue = Promise.resolve();
this.settings = new SettingsService({
get: async (key) => this.values[key],
@ -130,6 +147,7 @@ export const Runtime = {
'accessibility.monoaudio.enable':'accessibility.monoaudio.enable',
'geolocation.enabled':'geo.enabled',
'privacy.donottrackheader.enabled':'privacy.donottrackheader.enabled',
'privacy.trackingprotection.enabled':'privacy.trackingprotection.enabled',
'debug.paint-flashing.enabled':'nglayout.debug.paint_flashing',
'apz.overscroll.enabled':'apz.overscroll.enabled'
};
@ -140,12 +158,14 @@ export const Runtime = {
}
if (entries.some(([key]) => key === 'language.current'))
Services.prefs.setStringPref('intl.accept_languages', values['language.current']);
if (Services.env.get('VULPES_TUNDRA') === '1') {
if (Services.env.get('VULPES_TUNDRA') === '1' || beatlesHardware()) {
const volume = entries.find(([key, value]) => /^audio\.volume\.(content|notification|alarm|telephony|bt_sco)$/.test(key) && Number.isFinite(value));
if (volume) {
const maximum = /\.(telephony|bt_sco)$/.test(volume[0]) ? 5 : 15;
const level = Math.max(0,Math.min(1,volume[1]/maximum));
const control = maximum === 5 ? tundraCallAudio({operation:'set',volume:level}) : tundraHardware({operation:'volume',level});
const control = beatlesHardware()
? tundraRadio('volume',{stream:volume[0].split('.').pop(),level})
: maximum === 5 ? tundraCallAudio({operation:'set',volume:level}) : tundraHardware({operation:'volume',level});
await control.catch(error => console.error(error.message));
}
}
@ -171,7 +191,7 @@ export const Runtime = {
: null,
(type, message) => this.broadcast('sms-' + type, message, 'sms'),
path => tundraRadio('messageState', {path}),
Services.env.get('VULPES_TUNDRA_RADIO') === '1' ? {validate:mmsPayload,
Services.env.get('VULPES_TUNDRA_RADIO') === '1' && Services.env.get('VULPES_DEVICE') !== 'beatles' ? {validate:mmsPayload,
send:message=>tundraMms('send',mmsPayload(message)), status:path=>tundraMms('state',{path})} : null,
);
this.media = new Media((data) => this.broadcast('mediachange', data));
@ -185,6 +205,14 @@ export const Runtime = {
'deviceinfo.hardware': 'Tundra / Sargo',
'deviceinfo.firmware_revision': 'Android 9 / API 28 (vendor)',
});
} else if (Services.env.get('VULPES_DEVICE') === 'beatles') {
Object.assign(this.values, {
'deviceinfo.product_model': 'Nokia 2720 Flip',
'deviceinfo.product_device': 'beatles',
'deviceinfo.product_manufacturer': 'Nokia',
'deviceinfo.hardware': 'ARMv7 — prototype framebuffer',
'deviceinfo.firmware_revision': 'KaiOS 2.5.2.2 — session temporaire',
});
}
const alarmPath = PathUtils.join(PathUtils.profileDir, 'vulpes-alarms-v1.json');
this.alarms = new Alarms(
@ -227,6 +255,16 @@ export const Runtime = {
return null;
return this.apps.find((app) => app.origin === origin) || null;
},
inputIdentity(actor) {
const app = this.identity(actor);
if (app) return app;
const context = actor.browsingContext.top;
for (const entry of Views.entries.values()) {
if (entry.browser.browsingContext === context && entry.browser.isConnected &&
entry.browser.style.visibility === 'visible') return this.identity(entry.actor);
}
return null;
},
grants(app) {
const access = app.manifest.permissions?.settings?.access || '';
return [
@ -254,11 +292,15 @@ export const Runtime = {
return {error:{code:'METHOD_NOT_SUPPORTED'}};
}
if(method==='hardware.vibrate') {
if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}};
if(app.id!=='system' || (Services.env.get('VULPES_TUNDRA')!=='1' && !beatlesHardware())) return {error:{code:'NOT_SUPPORTED'}};
const pattern=params?.pattern;
if(!Array.isArray(pattern) || pattern.length>32 || pattern.some(n=>!Number.isInteger(n) || n<0 || n>10000) || pattern.reduce((a,b)=>a+b,0)>10000)
return {error:{code:'INVALID_VALUE'}};
try {await tundraHardware({operation:'vibrate',pattern});return {result:true};}
try {
if (beatlesHardware()) await tundraRadio('hardware',{settings:{operation:'vibrate',pattern}});
else await tundraHardware({operation:'vibrate',pattern});
return {result:true};
}
catch(error) {return {error:{code:error.message}};}
}
if(method==='input.request') {
@ -388,6 +430,12 @@ export const Runtime = {
return {result:await this.downloads.request(app,params)};
} catch(error) {return {error:{code:error.message || 'DOWNLOAD_ERROR'}};}
}
if (method === 'layout.current') return { result: {
mode: this.values['vulpes.layout'] || 'auto',
compactDefault: Services.env.get('VULPES_KEYPAD') === '1',
coverScreen: Services.env.get('VULPES_DEVICE') === 'beatles',
reducedMotion: Services.env.get('VULPES_DEVICE') === 'beatles',
} };
if (method === 'appearance.current') return { result: this.values['vulpes.theme'] || 'light' };
if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot')
@ -466,13 +514,15 @@ export const Runtime = {
)
return { error: { code: 'INVALID_VALUE' } };
this.screenPower ||= new ScreenPower(this.power, async (next, changed) => {
if (Services.env.get('VULPES_TUNDRA') === '1' && ['screenEnabled','screenBrightness'].includes(changed))
if (beatlesHardware() && ['screenEnabled','screenBrightness'].includes(changed))
await tundraRadio('hardware',{settings:{operation:'screen',enabled:next.screenEnabled,level:next.screenBrightness}});
else if (Services.env.get('VULPES_TUNDRA') === '1' && ['screenEnabled','screenBrightness'].includes(changed))
await tundraHardware({operation:'screen', enabled:next.screenEnabled, level:next.screenBrightness});
});
await this.screenPower.set(name, value);
const screen =
actor.browsingContext.top.embedderElement.ownerDocument.getElementById('screen');
screen.style.filter = Services.env.get('VULPES_TUNDRA') === '1' ? '' : `brightness(${this.power.screenEnabled ? this.power.screenBrightness : 0})`;
screen.style.filter = (Services.env.get('VULPES_TUNDRA') === '1' || beatlesHardware()) ? '' : `brightness(${this.power.screenEnabled ? this.power.screenBrightness : 0})`;
screen.inert = !this.power.screenEnabled;
return { result: { ...this.power } };
}
@ -552,7 +602,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme', 'vulpes.layout'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
if (
type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')

View file

@ -0,0 +1,75 @@
// Explicit, private regression marker only. Captures stay in memory and are discarded.
export async function auditTickets({win,evaluate,wait,tap,sleep,root}) {
const report={recordedAt:new Date().toISOString(),results:{}};
const step=async(name,run)=>{
try{report.results[name]={value:await run()};}
catch(error){report.results[name]={error:String(error),stack:error.stack};}
await IOUtils.writeJSON(root+'/logs/ticket-regressions.json',report);
};
const ev=(app,code)=>evaluate(app,code);
const launch=async app=>{
await ev('system',`await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
await wait(app,"return document.readyState==='complete';",15000);
await sleep(600);
};
win.focus();win.document.getElementById('system').focus();
await step('unlock',()=>ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;'));
await step('cameraOpen',async()=>{
await launch('camera');
await wait('camera','return !!app.camera.mozCamera && !app.camera.isBusy;',15000);
return ev('camera','const c=app.camera.mozCamera;return {mode:app.camera.mode,flash:c.capabilities.flashModes,photo:c.getPictureSize(),frames:c.vulpesTiming};');
});
await step('cameraFlash',()=>ev('camera',`const c=app.camera.mozCamera;
if(!c.capabilities.flashModes.includes('torch'))return {supported:false};
try{return await c.vulpesCameraControl({action:'flash',mode:'torch'});}
finally{await new Promise(r=>setTimeout(r,200));await c.vulpesCameraControl({action:'flash',mode:'off'});}`));
await step('cameraPicture',()=>ev('camera',`const b=await app.camera.mozCamera.takePicture(),i=await createImageBitmap(b);
const r={width:i.width,height:i.height,bytes:b.size};i.close();return r;`));
for(const lens of ['front','back']) await step('cameraSwitch-'+lens,async()=>{
await ev('camera',`window.__ticketOldCamera=app.camera.mozCamera;if(app.settings.cameras.selected('key')!==${JSON.stringify(lens)})app.settings.cameras.next();else window.__ticketOldCamera=null;return true;`);
await wait('camera','return !!app.camera.mozCamera && app.camera.mozCamera!==window.__ticketOldCamera && !app.camera.isBusy;',15000);
return ev('camera',`const c=app.camera.mozCamera, before=c.vulpesTiming.frames;
await new Promise(r=>setTimeout(r,1000));return {selected:app.settings.cameras.selected('key'),frames:c.vulpesTiming.frames-before,flash:c.capabilities.flashModes};`);
});
const principal=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI('http://camera.localhost:8765'),{});
const previous=Services.perms.testExactPermissionFromPrincipal(principal,'microphone');
await step('cameraVideo',async()=>{
try{
Services.perms.addFromPrincipal(principal,'microphone',Ci.nsIPermissionManager.ALLOW_ACTION,Ci.nsIPermissionManager.EXPIRE_SESSION);
await ev('camera','window.__ticketVideo=null;app.once("newmedia",v=>{window.__ticketVideo=v;});app.settings.mode.select("video");return true;');
await wait('camera','return app.camera.mode==="video" && !!app.camera.mozCamera && !app.camera.isBusy;',15000);
await tap('camera','.js-capture',10000);
await wait('camera','return app.camera.get("recording")==="started";',15000);
await sleep(2200);await tap('camera','.js-capture',10000);
await wait('camera','return !!window.__ticketVideo?.blob;',20000);
return await ev('camera',`const v=__ticketVideo,el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);
try{await Promise.race([new Promise((ok,no)=>{el.onloadeddata=ok;el.onerror=()=>no(Error('Decode failed'));}),new Promise((_,no)=>setTimeout(()=>no(Error('Decode timeout')),7000))]);
return {bytes:v.blob.size,type:v.blob.type,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}
catch(error){return {decodeError:String(error),mediaError:el.error?.code,bytes:v.blob.size,type:v.blob.type,header:Array.from(new Uint8Array(await v.blob.slice(0,32).arrayBuffer())),poster:v.poster?.blob?.size};}
finally{URL.revokeObjectURL(el.src);el.remove();}`);
}finally{
await ev('camera','app.camera.stopRecording();await app.camera.mozCamera?.vulpesFinishRecording?.();if(window.__ticketVideo){await navigator.getDeviceStorage("videos").delete(__ticketVideo.filepath);await navigator.getDeviceStorage("pictures").delete(__ticketVideo.poster.filepath);}app.settings.mode.select("picture");return true;').catch(()=>{});
if(previous===Ci.nsIPermissionManager.UNKNOWN_ACTION)Services.perms.removeFromPrincipal(principal,'microphone');
else Services.perms.addFromPrincipal(principal,'microphone',previous);
}
});
await step('emailCaret',async()=>{
await launch('email');
await tap('email','input:not([type=hidden])',10000);
return ev('email',`const e=document.activeElement;return {field:e.localName,focus:document.hasFocus(),caret:getComputedStyle(e).caretColor};`);
});
await step('galleryShare',async()=>{
await launch('gallery');
await wait('gallery','return typeof share==="function";',10000);
try {
await ev('gallery',`const c=document.createElement('canvas');c.width=80;c.height=80;
c.getContext('2d').fillRect(0,0,80,80);
share([await new Promise(r=>c.toBlob(r,'image/png'))],'vulpes-share-test.png');return true;`);
await wait('system','return document.querySelector("#action-menu-form")?.classList.contains("visible");',10000);
return await ev('system',`return [...document.querySelectorAll('#action-menu button[data-manifest]')].map(e=>e.dataset.manifest);`);
} finally {
await ev('system','document.querySelector("#action-menu button[data-action=cancel]")?.click();return true;');
}
});
await step('home',()=>ev('system','dispatchEvent(new CustomEvent("home"));return true;'));
}

View file

@ -1,3 +1,15 @@
import {radioState} from '../services/radio-state.mjs';
const RADIO_ERRORS = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', 'RADIO_TIMEOUT',
'RADIO_REQUEST_FAILED', 'RADIO_READBACK_FAILED', 'INVALID_REQUEST', 'APN_IN_USE', 'APN_NOT_FOUND', 'APN_WRITE_FAILED', 'APN_ROLLBACK_FAILED', 'CLOCK_REQUEST_FAILED', 'CLOCK_UNAVAILABLE', 'INVALID_NUMBER', 'INVALID_SMS', 'SMS_TOO_LONG', 'INVALID_CALL', 'NOT_SUPPORTED', 'CALL_AUDIO_UNAVAILABLE', 'HARDWARE_READBACK_FAILED'];
const socketControl = () => Services.env?.get('VULPES_SOCKET_CONTROL') === '1';
async function localControl(kind, params) {
const {unixControl}=ChromeUtils.importESModule('resource://vulpes/host/UnixControl.sys.mjs');
const reply=await unixControl(kind,params);
if(!reply || reply.error || reply.ok===false || !Object.hasOwn(reply,'result') ||
(['radio','hardware','mms'].includes(kind) && reply.ok!==true))
throw Error(kind==='radio' ? (RADIO_ERRORS.includes(reply?.error) ? reply.error : 'RADIO_REQUEST_FAILED') : reply?.error || 'HARDWARE_REQUEST_FAILED');
return reply.result;
}
// Native state is published by Tundra, never supplied by an application.
export async function tundraSnapshot() {
try {
@ -16,11 +28,12 @@ export async function tundraSnapshot() {
? battery
: null,
wifi: value.wifi,
radio: radioState(value.radio),
recordedAt: value.recordedAt,
available: true,
};
} catch (_) {
return { battery: null, wifi: { available: false, state: 'unavailable' }, available: false };
return { battery: null, radio: [], wifi: { available: false, state: 'unavailable' }, available: false };
}
}
@ -51,14 +64,15 @@ export function tundraCapabilities(release) {
export async function tundraRadio(operation, params = {}) {
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
if (!['deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
if (!['radioPower', 'hardware', 'volumeState', 'volume', 'deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState', 'callAudio'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
if(socketControl())return localControl('radio',{...params,version:1,operation});
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({
command: '/usr/bin/python3',
arguments: ['/etc/tundra/radio-control.py', '--client-envelope'],
command: Services.env.get('VULPES_DEVICE') === 'beatles' ? '/work/control-client' : '/usr/bin/python3',
arguments: Services.env.get('VULPES_DEVICE') === 'beatles' ? ['--radio'] : ['/etc/tundra/radio-control.py', '--client-envelope'],
stderr: 'ignore',
});
await process.stdin.write(JSON.stringify({ version: 1, operation, ...params }));
await process.stdin.write(JSON.stringify({ ...params, version: 1, operation }));
await process.stdin.close();
let output = '';
for (;;) {
@ -79,9 +93,8 @@ export async function tundraRadio(operation, params = {}) {
throw Error('RADIO_INVALID_REPLY');
}
if (reply?.ok === false) {
const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', 'RADIO_TIMEOUT',
'RADIO_REQUEST_FAILED', 'RADIO_READBACK_FAILED', 'INVALID_REQUEST', 'APN_IN_USE', 'APN_NOT_FOUND', 'APN_WRITE_FAILED', 'APN_ROLLBACK_FAILED', 'CLOCK_REQUEST_FAILED', 'CLOCK_UNAVAILABLE', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
throw Error(RADIO_ERRORS.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
}
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
return reply.result;
@ -117,10 +130,11 @@ export async function tundraWifi(params) {
}
async function wifiCommand(params) {
if(socketControl())return localControl('wifi',params);
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({
command: '/usr/bin/python3',
arguments: ['/etc/tundra/wifi-control.py', '--client'],
command: Services.env.get('VULPES_DEVICE') === 'beatles' ? '/work/control-client' : '/usr/bin/python3',
arguments: Services.env.get('VULPES_DEVICE') === 'beatles' ? ['--wifi'] : ['/etc/tundra/wifi-control.py', '--client'],
stderr: 'ignore',
});
// Passwords are never command-line arguments, preferences or log messages.
@ -144,6 +158,7 @@ async function wifiCommand(params) {
}
export async function tundraHardware(params) {
if(socketControl())return localControl('hardware',params);
if (Services.env.get('VULPES_TUNDRA') !== '1') return;
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({command:'/usr/bin/python3',
@ -156,6 +171,8 @@ export async function tundraHardware(params) {
}
export async function tundraCallAudio(params) {
if(socketControl())return localControl('audio',params);
if (Services.env?.get('VULPES_DEVICE') === 'beatles') return tundraRadio('callAudio',{settings:params});
const {Subprocess} = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({command:'/usr/bin/python3',
arguments:['/etc/tundra/call-audio.py','--client'],stderr:'ignore'});
@ -183,6 +200,7 @@ export async function tundraCallAudio(params) {
export async function tundraMms(operation, params={}) {
if(Services.env.get('VULPES_TUNDRA_RADIO')!=='1') throw Error('NotSupportedError');
if(!['send','state','incoming','ack'].includes(operation)) throw Error('INVALID_REQUEST');
if(socketControl())return localControl('mms',{...params,version:1,operation});
const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process=await Subprocess.call({command:'/usr/bin/python3',arguments:['/etc/tundra/mms-control.py','--client'],stderr:'ignore'});
await process.stdin.write(JSON.stringify({version:1,operation,...params}));await process.stdin.close();
@ -194,6 +212,7 @@ export async function tundraMms(operation, params={}) {
// A persistent daemon owns BlueZ discovery and pending pairing requests.
export async function tundraBluetooth(params) {
if (Services.env.get('VULPES_TUNDRA') !== '1') throw Error('NotSupportedError');
if(socketControl())return localControl('bluetooth',{...params,version:1});
const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process=await Subprocess.call({command:'/usr/bin/python3',
arguments:['/etc/tundra/bluetooth-control.py','--client'],stderr:'ignore'});

64
host/UnixControl.sys.mjs Normal file
View file

@ -0,0 +1,64 @@
// Fixed local brokers keep device permissions in Tundra. Gecko needs no child
// Python interpreter for each idle status poll.
import {setTimeout, clearTimeout} from 'resource://gre/modules/Timer.sys.mjs';
const routes = {
radio:['/run/tundra-radio/radio.sock',65536,120000],
wifi:['/run/tundra-wifi/control.sock',65536,120000],
bluetooth:['/run/tundra-bluetooth/control.sock',65536,120000],
hardware:['/run/tundra-buttons/control.sock',4096,5000],
audio:['/run/tundra-call-audio/control.sock',4096,10000],
mms:['/run/tundra-mms/mms.sock',1228800,120000],
};
export function unixControl(kind, params) {
if (!Object.hasOwn(routes,kind)) throw Error('INVALID_BROKER');
const [path,limit,timeout]=routes[kind];
return exchange(path,params,limit,timeout);
}
export function exchange(path, params, limit=65536, timeout=20000) {
const bytes=new TextEncoder().encode(JSON.stringify(params)+'\n');
if (bytes.length>1228800) return Promise.reject(Error('REQUEST_TOO_LARGE'));
return new Promise((resolve,reject)=>{
let transport,input,output,reader,timer,finished=false,received='',offset=0;
const finish=(error,value)=>{
if(finished)return;finished=true;clearTimeout(timer);
try{input?.close();}catch(_){}try{output?.close();}catch(_){}
try{transport?.close(Cr.NS_BINDING_ABORTED);}catch(_){}
if(error)reject(error);else resolve(value);
};
timer=setTimeout(()=>finish(Error('BROKER_TIMEOUT')),timeout);
try {
const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(path);
transport=Cc['@mozilla.org/network/socket-transport-service;1']
.getService(Ci.nsISocketTransportService).createUnixDomainTransport(file);
input=transport.openInputStream(0,0,0).QueryInterface(Ci.nsIAsyncInputStream);
output=transport.openOutputStream(0,0,0).QueryInterface(Ci.nsIAsyncOutputStream);
reader=Cc['@mozilla.org/scriptableinputstream;1'].createInstance(Ci.nsIScriptableInputStream);
reader.init(input);
const read={onInputStreamReady(){
if(finished)return;
try{
const count=input.available();
if(!count)throw Error('BROKER_CLOSED');
if(received.length+count>limit)throw Error('BROKER_REPLY_TOO_LARGE');
received+=reader.readBytes(count);
const end=received.indexOf('\n');
if(end>=0){
const data=Uint8Array.from(received.slice(0,end),c=>c.charCodeAt(0));
finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data)));
}else input.asyncWait(read,0,0,Services.tm.currentThread);
}catch(error){finish(error);}
}};
const write={onOutputStreamReady(){
if(finished)return;
try{
const chunk=String.fromCharCode(...bytes.subarray(offset,offset+8192));
offset+=output.write(chunk,chunk.length);
if(offset<bytes.length)output.asyncWait(write,0,0,Services.tm.currentThread);
}catch(error){finish(error);}
}};
input.asyncWait(read,0,0,Services.tm.currentThread);
output.asyncWait(write,0,0,Services.tm.currentThread);
}catch(error){finish(error);}
});
}

View file

@ -2,6 +2,21 @@
// real top-level browser, never by removing CSP or frame-ancestors protection.
export const Views = {
entries: new Map(),
syncContext(entry) {
const context=entry.browser.browsingContext;
if (!context || context.id===entry.topContextId) return;
// Site isolation may replace the top-level context on navigation.
const contexts=Services.ppmm.sharedData.get('vulpes:host-contexts') || [];
Services.ppmm.sharedData.set('vulpes:host-contexts',
[...new Set(contexts.filter(id=>id!==entry.topContextId).concat(context.id))]);
Services.ppmm.sharedData.flush();
entry.topContextId=context.id;
if (!Services.ppmm.sharedData.get('vulpes:native-touch')) {
context.inRDMPane=true;
context.setRDMPaneMaxTouchPoints(1);
context.touchEventsOverride='enabled';
}
},
remove(id, entry) {
const contextId = entry.topContextId;
try {
@ -44,9 +59,13 @@ export const Views = {
Object.assign(entry.browser.style,{left:(left-x)+'px',top:(top-y)+'px',
width:Math.max(0,right-left)+'px',height:Math.max(0,bottom-top)+'px',
visibility:visible && right>left && bottom>top?'visible':'hidden'});
// Remote Wayland surfaces need the same layout flush as the Gaia browser.
entry.browser.getBoundingClientRect();
entry.browser.frameLoader?.requestUpdatePosition?.();
},
prune() {
for (const [id, entry] of this.entries) {
this.syncContext(entry);
if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry);
continue;
@ -149,6 +168,7 @@ export const Views = {
]),
onLocationChange(progress, request, location) {
if (!progress.isTopLevel) return;
if (entry) Views.syncContext(entry);
if (location.spec === 'about:blank') return; // Initial, uncommitted context.
emit('locationchange', {
url: location.spec,

View file

@ -0,0 +1,294 @@
import {nextKeypadTarget} from 'resource://vulpes/services/keypad-navigation.mjs';
import {KeypadText} from 'resource://vulpes/services/keypad-text.mjs';
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
export class KeypadChild extends JSWindowActorChild {
receiveMessage(message) {
if (message.name === 'Vulpes:FocusBrowserToolbar') {
if (this.document.location.hostname !== 'system.localhost') return;
const bar = [...this.document.querySelectorAll('.chrome .urlbar')].find(element => {
if (!element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && element.contains(this.document.elementFromPoint(
rect.x + rect.width / 2, rect.y + rect.height / 2));
});
if (bar) this.select(bar);
return;
}
if (message.name !== 'Vulpes:Navigate') return;
const {key, code = '', keyCode = 0, repeat = false} = message.data;
if (typeof key !== 'string' || key.length > 32) return;
const document = this.document, win = this.contentWindow;
if (key === 'F2') {
if (!repeat) this.sendAsyncMessage('Vulpes:KeypadNotifications');
return;
}
const exposed = element => {
if (!element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return false;
const rect = element.getBoundingClientRect();
const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
return x >= 0 && x < win.innerWidth && y >= 0 && y < win.innerHeight &&
element.contains(document.elementFromPoint(x, y));
};
this.composer ||= new KeypadText();
if (key==='#' && !repeat) {
this.hashHoldHandled=false;
this.hashStartMode=this.composer.mode;
this.hashStartReturnMode=this.composer.returnMode;
}
const target = document.activeElement;
const editing = !target?.readOnly && (target?.isContentEditable || target?.localName === 'textarea' ||
(target?.localName === 'input' && ['text','search','email','url','tel','password','number'].includes(target.type)));
const modal = [...document.querySelectorAll('[aria-modal="true"],[data-type="confirm"],[data-type="action"]')]
.reverse().find(exposed);
if (!editing || modal) { this.composer.commit(); this.hint?.remove(); }
if (modal && !this.modalAnchor && target?.localName === 'select') this.modalAnchor = target;
if (!modal) this.modalAnchor = null;
const browserControls = target?.closest?.('.chrome .controls');
const inBrowserToolbar = browserControls && document.location.hostname === 'system.localhost';
// Keep toolbar keys in Gaia; Down returns to the browser's content frame.
if (!editing && !modal && (!inBrowserToolbar || key === 'ArrowDown')) {
for (const frame of document.querySelectorAll('iframe')) {
if (!frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) continue;
const rect = frame.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
if (document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) !== frame) continue;
if (frame.browsingContext) {
if (inBrowserToolbar) frame.focus();
const browserContent = message.data.browserContent ||
document.location.hostname === 'system.localhost' &&
!!frame.closest('.appWindow')?.querySelector('.chrome .urlbar');
this.sendAsyncMessage('Vulpes:KeypadRoute', {
contextId:frame.browsingContext.id, key, code, keyCode, repeat, browserContent,
});
return;
}
}
}
// The Nokia's red key maps to F4; keep Gaia's own call controls.
if (key === 'F4' || key === 'EndCall') {
if (repeat) return;
this.composer.commit(); this.hint?.remove();
const hangup = [...document.querySelectorAll('#callbar-hang-up,#incoming-reject')].find(exposed);
if (hangup && !hangup.disabled) hangup.click();
else this.sendAsyncMessage('Vulpes:KeypadHome');
return;
}
// Gaia's dial pad is touch-driven, including its own number model.
if (!modal && document.location.hostname === 'communications.localhost' && !editing) {
const digit = /^[0-9*#]$/.test(key) ? key : key === 'Backspace' ? 'delete' : null;
const button = digit && document.querySelector('[role="key"][data-value="'+digit+'"]');
if (button && exposed(button)) { this.activate(button); return; }
}
if (!modal && key === 'F1') {
const button = [...document.querySelectorAll('#callbar-answer,#incoming-answer,#keypad-callbar-call-action')].find(exposed);
if (button && !button.disabled) { button.click(); return; }
}
if (editing && !modal && !['Escape', 'BrowserBack', 'GoBack', 'ContextMenu', 'ArrowUp', 'ArrowDown'].includes(key)) {
if (key==='#' && repeat && !this.hashHoldHandled &&
Services.env.get('VULPES_DEVICE')==='beatles' && !['tel','number'].includes(target.type)) {
this.hashHoldHandled=true;
this.composer.setT9(this.hashStartMode!=='t9',
this.hashStartMode==='t9' ? this.hashStartReturnMode : this.hashStartMode);
if (this.composer.mode==='t9') this.loadT9Dictionary();
this.showMode(target);
return;
}
if (!repeat || !/^[0-9*#]$/.test(key)) this.editKey(target, key);
return;
}
if (['Escape', 'BrowserBack', 'GoBack', 'Backspace'].includes(key)) {
this.composer.commit(); this.hint?.remove();
const addressCancel = document.querySelector('#rocketbar.active #rocketbar-cancel');
if (addressCancel && exposed(addressCancel)) { addressCancel.click(); return; }
const cancel = modal?.querySelector('[data-action="cancel"],button.cancel,button[data-l10n-id="cancel"]');
if (cancel) { cancel.click(); this.modalAnchor?.focus({preventScroll:true}); return; }
const header = [...document.querySelectorAll('gaia-header[action="back"],gaia-header[action="close"]')]
.find(exposed);
const back = header?.shadowRoot?.querySelector('.action-button') || header?.querySelector('.action-button') ||
[...document.querySelectorAll('[data-action="back"],button.back,[role="button"].back')]
.find(exposed);
if (back) back.click();
else this.sendAsyncMessage('Vulpes:KeypadHome');
return;
}
if (key === 'ContextMenu') {
this.sendAsyncMessage('Vulpes:KeypadCards');
return;
}
if (!modal && target?.localName === 'select' && ['Enter','Accept','Select'].includes(key)) {
target.click();
return;
}
if (!modal && target?.type === 'range' && ['ArrowLeft','ArrowRight'].includes(key)) {
key === 'ArrowLeft' ? target.stepDown() : target.stepUp();
target.dispatchEvent(new win.Event('input', {bubbles:true}));
target.dispatchEvent(new win.Event('change', {bubbles:true}));
return;
}
this.composer.commit(); this.hint?.remove();
const arrows = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
if (!arrows.includes(key) && !['Enter', 'Accept', 'Select'].includes(key)) {
return;
}
const root = modal || (inBrowserToolbar ? browserControls : document);
const candidates = [...root.querySelectorAll('a[href],button,input,select,textarea,[contenteditable="true"],[role="button"],[role="key"],[role="tab"],[tabindex]:not([tabindex="-1"]),[data-vulpes-keypad-focus],gaia-app-icon,gaia-pin-card,gaia-switch,gaia-checkbox')];
if (inBrowserToolbar) candidates.push(...browserControls.querySelectorAll('.urlbar'));
for (const header of root.querySelectorAll('gaia-header'))
candidates.push(...(header.shadowRoot?.querySelectorAll('button') || []));
const elements = [...new Set(candidates)].filter(element => {
const rect = element.getBoundingClientRect();
return !element.disabled && !element.hasAttribute('disabled') && !element.readOnly && element.getAttribute('aria-disabled') !== 'true' &&
element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.left < win.innerWidth;
});
if (!elements.length) {
if (key === 'ArrowUp' && message.data.browserContent && win.scrollY <= 0)
this.sendAsyncMessage('Vulpes:KeypadToolbar');
else win.scrollBy({top:key === 'ArrowUp' ? -80 : key === 'ArrowDown' ? 80 : 0});
return;
}
const focused = elements.includes(document.activeElement) ? document.activeElement : null;
const compactSelection = document.documentElement.dataset.vulpesLayout === 'compact' ?
document.querySelector('[data-vulpes-compact-selected]') : null;
const current = elements.indexOf(focused || compactSelection || (this.selected?.isConnected && this.selected));
const rects = elements.map(element => {
const rect = element.getBoundingClientRect();
return {x:rect.x + rect.width / 2, y:rect.y + rect.height / 2};
});
if (!arrows.includes(key)) {
this.activate(elements[current < 0 ? 0 : current]);
if (modal && !modal.isConnected) this.modalAnchor?.focus({preventScroll:true});
return;
}
const index = nextKeypadTarget(rects, current, key);
if (key === 'ArrowUp' && message.data.browserContent && win.scrollY <= 0 && (current < 0 || index < 0)) {
this.sendAsyncMessage('Vulpes:KeypadToolbar');
return;
}
if (index < 0) {
win.scrollBy({top:key === 'ArrowUp' ? -80 : key === 'ArrowDown' ? 80 : 0});
return;
}
this.select(elements[index]);
}
select(element) {
const document = this.document;
if (!this.style) {
this.style = document.createElement('style');
this.style.textContent = '[data-vulpes-keypad-focus]{outline:2px solid #00aaff !important;outline-offset:-2px !important;}';
document.documentElement.append(this.style);
}
this.composer?.commit(); this.hint?.remove();
this.selected?.removeAttribute('data-vulpes-keypad-focus');
this.selected = element;
this.selected.setAttribute('data-vulpes-keypad-focus', '');
if (!this.selected.hasAttribute('tabindex')) this.selected.setAttribute('tabindex', '-1');
this.selected.focus({preventScroll:true});
this.selected.scrollIntoView({block:'nearest',inline:'nearest'});
}
activate(element) {
if (element.disabled || element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') return;
if (['gaia-switch', 'gaia-checkbox'].includes(element.localName)) {
// The component owns toggling and change events inside its shadow tree.
// Calling the host's native click from this actor bypasses that handler.
element.shadowRoot?.querySelector(element.localName === 'gaia-switch' ? '#switch' : '#checkbox')?.click();
return;
}
if (element.getAttribute('role') === 'key' && element.hasAttribute('data-value')) {
for (const type of ['touchstart','touchend'])
element.dispatchEvent(new this.contentWindow.Event(type,{bubbles:true,cancelable:true}));
} else element.click();
}
editKey(element, key) {
const editor = new InputEditor(this.contentWindow);
editor.focus(element);
if (element.classList.contains('recipient') && ['Enter','Backspace'].includes(key)) {
const keyCode = key === 'Enter' ? 13 : 8;
// Gaia's recipients consume keypress, but a synthetic KeyboardEvent also
// enters unsupported native key-binding IPC in the standalone host.
const event = new this.contentWindow.Event('keypress', {bubbles:true,cancelable:true});
for (const [name,value] of Object.entries({key,keyCode,which:keyCode,
DOM_VK_BACK_SPACE:8,DOM_VK_ENTER:14,DOM_VK_TAB:9}))
Object.defineProperty(event.wrappedJSObject,name,{value});
if (!element.dispatchEvent(event)) { this.composer.commit(); return; }
}
const state = editor.state();
const numeric = ['tel','number'].includes(state.inputType) ||
['tel','numeric','decimal'].includes(state.inputMode);
if (/^[0-9*#]$/.test(key)) {
if (this.composer.mode==='t9' && this.t9Loading && /^[2-9]$/.test(key)) {
this.t9Queue ||= [];
if (this.t9Queue.length<16) this.t9Queue.push(key);
return;
}
const operation = this.composer.press(key, {field:element,
text:element.isContentEditable ? element.textContent : element.value,
start:state.selectionStart, end:state.selectionEnd, numeric});
if (operation?.text !== undefined) {
if (operation.replace) editor.edit({operation:'replace',offset:-operation.replace,
length:operation.replace,text:operation.text});
else editor.edit({operation:'key',key:operation.text});
}
if (operation?.mode === 't9') this.loadT9Dictionary();
if (!numeric) this.showMode(element);
} else {
this.composer.commit();
if (key === 'ArrowLeft' || key === 'ArrowRight') {
const start = key === 'ArrowLeft' ? Math.max(0, state.selectionStart - 1) :
Math.min(state.text.length, state.selectionEnd + 1);
editor.edit({operation:'selection', start, length:0});
} else if (key.length === 1 || ['Backspace', 'Enter'].includes(key)) {
editor.edit({operation:'key', key});
}
}
}
showMode(element) {
if (!this.hint) {
this.hint = this.document.createElement('span');
this.hint.id = 'vulpes-keypad-mode';
this.hint.style.cssText = 'position:fixed;right:4px;bottom:4px;z-index:2147483647;pointer-events:none;padding:2px 5px;border-radius:3px;background:#102337;color:white;font:12px sans-serif';
this.contentWindow.addEventListener('blur', () => {
this.composer.commit(); this.hint.remove();
}, true);
}
if (this.composer.mode==='t9') {
const french=(this.document.documentElement.lang || this.contentWindow.navigator.language || '').toLowerCase().startsWith('fr');
this.hint.textContent=(french ? 'T9 · maintien # · * ' : 'T9 · hold # · * ')+
(this.t9Loading ? '…' : this.composer.suggestion || '');
} else this.hint.textContent=this.composer.mode+' · #';
this.document.documentElement.append(this.hint);
}
loadT9Dictionary() {
const win=this.contentWindow;
const locale=(this.document.documentElement.lang || win.navigator.language || 'en').toLowerCase();
if (this.t9Loading || this.composer.dictionary.size && this.t9Locale===locale) return;
this.t9Loading=true;
this.composer.dictionary=new Map();
const choices=[locale,locale.split('-')[0]];
if (locale==='en-us') choices.unshift('en_us');
if (locale==='en-gb') choices.unshift('en_gb');
if (locale==='pt-br') choices.unshift('pt_br');
if (locale==='pt-pt') choices.unshift('pt_pt');
const root=Services.env.get('VULPES_WORKSPACE_ROOT');
if (!root) { this.t9Loading=false; return; }
this.t9Field=this.document.activeElement;
const {IOUtils}=ChromeUtils.importESModule('resource://gre/modules/IOUtils.sys.mjs');
(async()=>{
for (const language of choices) {
if (!/^(?:[a-z]{2,3}(?:_[a-z]{2}|-[a-z]{2}|-latn|-cyrl)?)$/.test(language)) continue;
try {
const bytes=await IOUtils.read(root+'/tundra/devices/beatles/t9/'+language+'.tsv');
this.composer.setDictionary(new TextDecoder().decode(bytes));
if (this.composer.dictionary.size) { this.t9Locale=locale; break; }
} catch (_) {}
}
this.t9Loading=false;
if (this.hint?.isConnected) this.showMode(this.document.activeElement);
if (this.document.activeElement===this.t9Field) {
for (const key of this.t9Queue || []) this.editKey(this.t9Field,key);
}
this.t9Queue=[];
})();
}
}

View file

@ -0,0 +1,44 @@
import {Runtime} from 'resource://vulpes/host/Runtime.sys.mjs';
import {Views} from 'resource://vulpes/host/Views.sys.mjs';
export class KeypadParent extends JSWindowActorParent {
receiveMessage(message) {
if (Services.env.get('VULPES_KEYPAD') !== '1') return;
if (message.name === 'Vulpes:KeypadNotifications') {
Runtime.broadcast('keypad-notifications', {}, 'system');
return;
}
if (message.name === 'Vulpes:KeypadToolbar') {
const shell = this.browsingContext.top.embedderElement?.ownerDocument;
const system = shell?.getElementById('system')?.browsingContext;
const belongsToHost = this.browsingContext.top === system ||
[...Views.entries.values()].some(entry => entry.browser.browsingContext === this.browsingContext.top);
if (belongsToHost) {
system?.currentWindowGlobal?.getActor('Keypad')
.sendAsyncMessage('Vulpes:FocusBrowserToolbar', {});
}
return;
}
if (message.name === 'Vulpes:KeypadRoute') {
const child = BrowsingContext.get(message.data.contextId);
let ancestor = child?.parent;
while (ancestor && ancestor !== this.browsingContext) ancestor = ancestor.parent;
if (ancestor !== this.browsingContext) return;
const view = Views.entries.get(child.id);
const destination = view && view.browser.style.visibility === 'visible' ? view.browser.browsingContext : child;
const page = destination.currentWindowGlobal;
// Gecko error pages can autofocus a retry button far below the viewport.
// Up must still offer an immediate way to edit the failed address.
if (message.data.key === 'ArrowUp' &&
/^about:(neterror|certerror|httpsonlyerror)\?/.test(page?.documentURI.spec || '')) {
this.receiveMessage({name:'Vulpes:KeypadToolbar'});
return;
}
page?.getActor('Keypad').sendAsyncMessage('Vulpes:Navigate', message.data);
return;
}
if (!['Vulpes:KeypadHome', 'Vulpes:KeypadCards'].includes(message.name)) return;
Views.hideAll();
Runtime.broadcast(message.name === 'Vulpes:KeypadCards' ? 'holdhome' : 'home', {}, 'system');
}
}

View file

@ -3,9 +3,13 @@ import { Views } from 'resource://vulpes/host/Views.sys.mjs';
import { packageURL } from 'resource://vulpes/services/packages.mjs';
export class VulpesMarketplaceParent extends JSWindowActorParent {
receiveMessage(message) {
if(message.name!=='Marketplace:Download' || this.manager.documentPrincipal.originNoSuffix!=='https://vulpes-os.org')return;
if(message.name!=='Marketplace:Download')return;
const page=this.manager.documentURI;
if(!((this.manager.documentPrincipal.originNoSuffix==='https://vulpes-os.org' && page.pathQueryRef.startsWith('/marketplace/')) ||
(this.manager.documentPrincipal.originNoSuffix==='https://ffapps.danielherr.software' && page.pathQueryRef.startsWith('/app/'))))return;
const view=[...Views.entries.values()].find(v=>v.browser.browsingContext.top.id===this.browsingContext.top.id);
if(!view || !Runtime.identity(view.actor))return;
if(view.browser.style.visibility!=='visible')return;
Runtime.marketplaceDownload=packageURL(message.data.url);
const app=Runtime.apps.find(a=>a.id==='marketplace');
Runtime.broadcast('launch',{manifestURL:app.manifestURL,origin:app.origin,url:app.origin+'/index.html',timestamp:Date.now()},'system');

View file

@ -1,4 +1,6 @@
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
import {frameUnobscured} from 'resource://vulpes/services/view-visibility.mjs';
import {requestMediaPermission} from 'resource://vulpes/services/media-permission.mjs';
export class VulpesChild extends JSWindowActorChild {
handleEvent(event) {
if(['focusin','pointerdown','click'].includes(event.type)) {
@ -41,20 +43,7 @@ export class VulpesChild extends JSWindowActorChild {
params.contextId = native.browsingContext.id;
if (params.operation === 'geometry') {
const rect = native.getBoundingClientRect();
const samples = [
[0.5, 0.5],
[0.05, 0.05],
[0.95, 0.05],
[0.05, 0.95],
[0.95, 0.95],
];
const unobscured = samples.every(
([x, y]) =>
this.document.elementFromPoint(
rect.left + rect.width * x,
rect.top + rect.height * y,
) === native,
);
const unobscured = frameUnobscured(this.document, native, rect);
params.geometry = {
left: rect.left + win.mozInnerScreenX,
top: rect.top + win.mozInnerScreenY,
@ -84,6 +73,11 @@ export class VulpesChild extends JSWindowActorChild {
inputState() { return this.editor.state(); }
edit(params) { return this.editor.edit(params); }
receiveMessage(message) {
if (message.name === 'Vulpes:MediaPermission') {
if (this.document.location.hostname !== 'system.localhost' || this.browsingContext.parent)
throw new Error('Invalid permission UI');
return requestMediaPermission(this.contentWindow, message.data, value => Cu.cloneInto(value, this.contentWindow));
}
if(message.name==='Vulpes:Edit') return this.edit(message.data);
if (message.name === 'Vulpes:ViewState') {
const frame = Array.from(this.document.querySelectorAll('iframe')).find(

View file

@ -0,0 +1,29 @@
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
// Web pages may edit their focused field, but receive no Gaia service bridge.
export class WebInputChild extends JSWindowActorChild {
handleEvent(event) {
if (!Services.cpmm.sharedData.get('vulpes:native-input') || this.editor?.editing) return;
const contexts = Services.cpmm.sharedData.get('vulpes:host-contexts') || [];
if (!contexts.includes(this.browsingContext.top.id)) return;
// Standalone web views have no Firefox tabbrowser to focus their embedder.
// Keep Gecko's picker focus checks intact by activating only a touched view.
if (event.isTrusted && ['pointerdown','mousedown','click'].includes(event.type))
this.sendAsyncMessage('Vulpes:WebActivate', null);
let element = event.composedPath()[0];
if (element?.isContentEditable) element = element.closest('[contenteditable=true]') || element;
const editable = element?.isContentEditable || element?.localName === 'textarea' ||
(element?.localName === 'input' && ['text','search','email','url','tel','password','number'].includes(element.type));
if (!editable || element.disabled || element.readOnly) {
if (event.type === 'click') this.sendAsyncMessage('Vulpes:InputFocus', null);
return;
}
this.editor ||= new InputEditor(this.contentWindow);
this.editor.focus(element);
this.sendAsyncMessage('Vulpes:InputFocus', this.editor.state());
}
receiveMessage(message) {
if (message.name !== 'Vulpes:Edit' || !this.editor) throw Error('INPUT_CONTEXT_EXPIRED');
return this.editor.edit(message.data);
}
}

View file

@ -0,0 +1,22 @@
import {Views} from 'resource://vulpes/host/Views.sys.mjs';
import {Runtime} from 'resource://vulpes/host/Runtime.sys.mjs';
export class WebInputParent extends JSWindowActorParent {
receiveMessage(message) {
if (message.name === 'Vulpes:WebActivate') {
if (Runtime.identity(this) || !Runtime.inputIdentity(this)) return;
for (const entry of Views.entries.values()) {
if (entry.browser.browsingContext === this.browsingContext.top &&
entry.browser.ownerDocument.hasFocus()) {
entry.browser.focus();
break;
}
}
return;
}
if (message.name !== 'Vulpes:InputFocus') throw Error('METHOD_NOT_SUPPORTED');
if (Runtime.identity(this)) return; // Bundled apps already use VulpesChild.
Runtime.input.changed(this, message.data);
}
didDestroy() { Runtime.input.cleanup(this); }
}

View file

@ -175,6 +175,12 @@ html[data-vulpes-app='video'] #in-use-overlay-content {
min-width: 0;
overflow-y: auto;
}
/* The old one-pixel tile produces moiré at fractional phone scale factors. */
html[data-vulpes-app='gallery'] #overlay-content {
background-image: url('/style/images/ui/gradient.png');
background-size: 100% 100%;
background-repeat: no-repeat;
}
html[data-vulpes-app='video'] #overlay-text,
html[data-vulpes-app='video'] #in-use-overlay-text {
width: auto;

View file

@ -328,8 +328,8 @@
return request;
}
async function openCamera(name, config, ticket) {
if (config.mode && config.mode !== 'picture')
throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
if (config.mode && !['picture', ...(videoMime ? ['video'] : [])].includes(config.mode))
throw new DOMException('Unsupported capture mode', 'NotSupportedError');
if (current) await releaseStream(current);
await closing;
if (document.hidden || ticket !== generation)
@ -406,7 +406,8 @@
const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
let focusArea = null, zoom = 1, mode = 'picture', recording, startingRecording = false;
let flashMode = 'off', flashUpdate = Promise.resolve();
let focusArea = null, zoom = 1, mode = config.mode || 'picture', recording, startingRecording = false;
const profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:'vp8',bitrate:1500000,framerate:30},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
const configuration = () => ({
mode,
@ -420,7 +421,7 @@
previewSizes: [size],
thumbnailSizes: [],
recorderProfiles: videoMime ? {webm:profile} : {},
flashModes: ['off'],
flashModes: native?.flashModes?.length ? native.flashModes : ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'],
whiteBalanceModes: ['auto'],
@ -472,6 +473,7 @@
},
setThumbnailSize() {},
async takePicture() {
await flashUpdate;
if (native) {
const result=await stream.vulpesCameraControl({action:'picture',size:pictureSize});
emit(stream,'shutter');
@ -555,6 +557,16 @@
const done=recording.done;stream.stopRecording();await done;
},
});
if (native) Object.defineProperty(stream,'flashMode',{
configurable:true, get:()=>flashMode,
set(value) {
if (!stream.capabilities.flashModes.includes(value) || value===flashMode) return;
const previous=flashMode;
flashMode=value;
flashUpdate=flashUpdate.catch(()=>{}).then(()=>stream.vulpesCameraControl({action:'flash',mode:value}));
flashUpdate.catch(error=>{if(flashMode===value)flashMode=previous;recordError(error);});
}
});
if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom,
set(value) {
@ -568,7 +580,7 @@
});
dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', {
detail: { cameras: [...cameras], flash: false, video: !!videoMime },
detail: { cameras: [...cameras], flash: stream.capabilities.flashModes.length > 1, video: !!videoMime },
}),
);
return { camera: stream, configuration: configuration() };
@ -602,7 +614,7 @@
const controls = [
[
'.js-flash',
true,
!current || current.capabilities?.flashModes?.length < 2,
'Flash indisponible dans cette Preview',
'Flash unavailable in this Preview',
'flash-off',

View file

@ -102,7 +102,10 @@
async function refreshCalls() {
try {
const current = await call('platform.calls',{});
if (location.hostname === 'callscreen.localhost') Object.assign(audioState,await call('platform.callAudio',{operation:'status'}));
if (location.hostname === 'callscreen.localhost' && current.some(c=>c.state!=='disconnected')) {
// Audio routing may be unavailable while modem call control still works.
try { Object.assign(audioState,await call('platform.callAudio',{operation:'status'})); } catch (_) {}
}
const live = current.filter(c=>c.state!=='disconnected');
const old = telephony.calls;
let changed = false;
@ -188,6 +191,7 @@
return r;
}
let language = navigator.language;
let languageRevision = 0;
function setLanguage(value) {
if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/.test(value)) return;
const changed = language !== value;
@ -196,7 +200,10 @@
}
Object.defineProperty(navigator, 'language', {get:() => language});
Object.defineProperty(navigator, 'languages', {get:() => Object.freeze([language])});
call('locale.current').then(setLanguage).catch(() => {});
const initialLanguageRevision = languageRevision;
call('locale.current').then(value => {
if (languageRevision === initialLanguageRevision) setLanguage(value);
}).catch(() => {});
const observers = new Map();
const settings = new EventTarget();
settings.createLock = () => ({
@ -228,7 +235,10 @@
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type === 'settingchange') {
if (data.key === 'language.current') setLanguage(data.value);
if (data.key === 'language.current') {
languageRevision++;
setLanguage(data.value);
}
const change = {
settingName: data.key,
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value,
@ -238,7 +248,17 @@
}
if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data}));
if (type === 'activity-choice') dispatchEvent(new CustomEvent('mozChromeEvent', {detail:{...data,type:'activity-choice'}}));
if (type === 'keypad-notifications' && location.hostname === 'system.localhost') {
const tray = window.UtilityTray;
if (tray?.motion) {
if (['open', 'opening'].includes(tray.motion.state)) tray.hide(true);
else if (!window.Service?.query('locked')) tray.show(true);
}
}
if (type === 'activity-choice') {
dispatchEvent(new CustomEvent('activityrequesting'));
dispatchEvent(new CustomEvent('mozChromeEvent', {detail:{...data,type:'activity-choice'}}));
}
if (type === 'activity-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}}));
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
if (type === 'screen-off') {

View file

@ -76,16 +76,37 @@
if(event.source===frame?.contentWindow && event.origin==='http://keyboard.localhost:8765' &&
event.data?.type==='vulpes-keyboard-ready') sendViewport();
});
let resizeSequence=0;
const resize=next=>{
if(next===height) return;
height=next;
const sequence=++resizeSequence, pending=[];
if(frame) {frame.style.height=height+'px';frame.hidden=!height;}
dispatchEvent(new CustomEvent(height?'keyboardchange':'keyboardhide',{detail:{height,waitUntil:()=>{}}}));
dispatchEvent(new CustomEvent(height?'keyboardchange':'keyboardhide',{
detail:{height,waitUntil:promise=>pending.push(Promise.resolve(promise))}
}));
// AppWindowManager waits for this completion before opening the next app.
// A refocus while layout settles must not complete an obsolete hide.
if(!height) Promise.allSettled(pending).then(()=>{
if(sequence===resizeSequence && !height)
dispatchEvent(new CustomEvent('keyboardhidden'));
});
};
const attach=setInterval(()=>{
if(!window.Service) return;
clearInterval(attach);
Service.registerState('getHeight',{name:'InputWindowManager',getHeight:()=>height});
const hide=()=>call('input.request',{operation:'hide'});
const provider=window.inputWindowManager || {name:'InputWindowManager'};
provider.getHeight=()=>height;
provider.hideInputWindow=hide;
provider.hideInputWindowImmediately=hide;
Service.registerState('getHeight',provider);
// KeyboardManager is disabled with the host input API, so its original
// InputWindowManager may never exist. Register the replacement explicitly.
if(!window.inputWindowManager) {
Service.register('hideInputWindow',provider);
Service.register('hideInputWindowImmediately',provider);
}
},100);
addEventListener('vulpes-service-event',({detail})=>{
if(detail.type==='input-height') {if(frame && !frame.hidden) resize(detail.data.height);return;}
@ -102,7 +123,7 @@
frame.hidden=false;
resize(height || 260);
});
for(const type of ['home','lockscreen-appopened','appwillclose'])
for(const type of ['home','lockscreen-appopened','appwillclose','activityrequesting'])
addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error));
}
}

View file

@ -1,7 +1,107 @@
// Native status and radio power use the original Gaia SIM and signal components.
// Other control APIs stay unavailable until the device adapter implements them.
(() => {
if (!window.VulpesNativeRadioStatus || !['system.localhost','settings.localhost'].includes(location.hostname)) return;
const requestFrom = task => {
// Gaia uses DOMRequest callbacks; newer consumers can await the same request.
const request=Object.assign(new EventTarget(),{readyState:'pending',result:null,error:null});
const promise=Promise.resolve().then(task);
request.then=(resolve,reject)=>promise.then(resolve,reject);
promise.then(result=>{
request.result=result;request.readyState='done';
const event=new Event('success');request.dispatchEvent(event);request.onsuccess?.(event);
},error=>{
request.error=error;request.readyState='done';
const event=new Event('error');request.dispatchEvent(event);request.onerror?.(event);
});
return request;
};
const unsupported = () => requestFrom(()=>{
throw new DOMException('Not supported by this adapter','RequestNotSupported');
});
const emit = (object,type,detail={}) => {
const event=Object.assign(new Event(type),detail);
object.dispatchEvent(event);object['on'+type]?.(event);
};
const emptyInfo=()=>({connected:false,state:'unknown',type:null,network:null,relSignalStrength:null,roaming:false,emergencyCallsOnly:false});
const powerChangedAt=[0,0];
const connections=Array.from({length:2},(_,index)=>Object.assign(new EventTarget(),{
serviceId:index,iccId:null,radioState:'unknown',voice:emptyInfo(),data:emptyInfo(),
supportedNetworkTypes:[],networkSelectionMode:'automatic',
getNetworks:unsupported,selectNetwork:unsupported,selectNetworkAutomatically:unsupported,
setRadioEnabled(enabled) {
return requestFrom(async()=>{
const state=await VulpesCompat.call('platform.radioPower',{slot:index,enabled});
powerChangedAt[index]=Date.now()/1000;
const changed=this.radioState!==state.radioState;
this.radioState=state.radioState;
if(changed)emit(this,'radiostatechange');
return null;
});
},getCallForwardingOption:unsupported,setCallForwardingOption:unsupported,
getCallWaitingOption:unsupported,setCallWaitingOption:unsupported,
getCallingLineIdRestriction:unsupported,setCallingLineIdRestriction:unsupported,
getVoicePrivacyMode:unsupported,setVoicePrivacyMode:unsupported,
getRoamingPreference:unsupported,setRoamingPreference:unsupported,
getPreferredNetworkType:unsupported,setPreferredNetworkType:unsupported,
exitEmergencyCbMode:unsupported,
}));
const cards=new Map();
const manager=Object.assign(new EventTarget(),{iccIds:[],getIccById:id=>cards.get(id)||null});
Object.defineProperty(navigator,'mozMobileConnections',{value:connections});
Object.defineProperty(navigator,'mozIccManager',{value:manager});
async function refresh() {
let slots=[],recordedAt=0;
try { const snapshot=await VulpesCompat.call('platform.snapshot',{});slots=snapshot.radio || [];recordedAt=snapshot.recordedAt || 0; } catch (_) {}
document.documentElement.toggleAttribute('data-vulpes-radio-unavailable',
!connections.every((_,index)=>slots[index]?.available && typeof slots[index]?.sim?.present==='boolean'));
connections.forEach((conn,index)=>{
const slot=slots[index];
const previous=conn.iccId;
// An opaque slot handle is sufficient here; no ICCID/IMSI is requested.
conn.iccId=slot?.available && slot.sim?.present ? 'tundra-slot-'+index : null;
const previousRadio=conn.radioState;
// A cached snapshot must not undo a just-confirmed power command.
if(recordedAt>=powerChangedAt[index]) conn.radioState=slot?.available ? slot.radioState : 'unknown';
for(const kind of ['voice','data']) {
conn[kind]=Object.assign(emptyInfo(),slot?.available ? slot[kind] : {},{
network:slot?.operator || null,relSignalStrength:slot?.signal ?? null,
});
}
if(previous!==conn.iccId && previous) {
cards.delete(previous);manager.iccIds=[...cards.keys()];
emit(manager,'iccundetected',{iccId:previous});
}
if(conn.iccId) {
if(!cards.has(conn.iccId)) {
const card=Object.assign(new EventTarget(),{cardState:'unknown',iccInfo:null});
for(const name of ['getCardLock','unlockCardLock','setCardLock','getCardLockRetryCount','readContacts','updateContact',
'iccOpenChannel','iccExchangeAPDU','iccCloseChannel','sendStkResponse','sendStkMenuSelection','sendStkTimerExpiration','sendStkEventDownload'])card[name]=unsupported;
card.getServiceState=unsupported;cards.set(conn.iccId,card);
}
const card=cards.get(conn.iccId);
card.cardState=slot.sim.cardState;
card.iccInfo={iccid:conn.iccId,spn:slot.operator?.longName || '',iccType:'sim'};
manager.iccIds=[...cards.keys()];
if(previous!==conn.iccId)emit(manager,'iccdetected',{iccId:conn.iccId});
emit(card,'cardstatechange');emit(card,'iccinfochange');
}
manager.iccIds=[...cards.keys()];
if(previousRadio!==conn.radioState)emit(conn,'radiostatechange');
for(const type of ['voicechange','datachange','iccchange'])emit(conn,type);
});
}
document.documentElement.setAttribute('data-vulpes-radio-unavailable','');
const style=document.createElement('style');
style.textContent='[data-vulpes-radio-unavailable] #statusbar-mobile-connection{display:none!important}';
document.documentElement.append(style);
addEventListener('DOMContentLoaded',()=>{refresh();setInterval(refresh,5000);},{once:true});
})();
// Expose the single native SIM to the original Gaia Settings controllers.
// Other applications retain their existing telephony/message adapters.
(() => {
if (location.hostname !== 'settings.localhost' || !window.VulpesNativeTelephony) return;
if (location.hostname !== 'settings.localhost' || !window.VulpesNativeTelephony || window.VulpesNativeRadioStatus) return;
const {call} = VulpesCompat;
const read = action => call('platform.mobileSettings', {action});
const unavailable = () => Promise.reject(new DOMException('Not supported by this adapter', 'NotSupportedError'));

View file

@ -125,3 +125,177 @@
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
/* Shared compact home: three columns, original icons, reversible wallpaper veil. */
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] {
--compact-cell: round(down, calc((100vw - 12px) / 3), 1px);
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] body {
background-color: rgba(5, 15, 31, .72);
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps .icon-container {
width: var(--compact-cell);
height: 74px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps gaia-app-icon {
width: 52px;
left: round(down, calc((var(--compact-cell) - 52px) / 2), 1px);
border: 2px solid transparent;
--vulpes-label-width: calc(var(--compact-cell) - 4px);
--vulpes-label-offset: round(down, calc((48px - var(--vulpes-label-width)) / 2), 1px);
--vulpes-icon-display: block;
--vulpes-icon-title-position: absolute;
--vulpes-icon-title-clip: inset(50%);
font-size: 12px;
line-height: 16px;
transform: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps-panel > .scrollable {
padding-top: 2px;
padding-bottom: 0;
}
/* A single applications panel; saved pinned pages remain available in classic mode. */
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] :is(#page-indicator-header, #pages-panel, #shadow) {
display: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #panels {
height: calc(100vh - 52px);
overflow-x: hidden;
scroll-snap-type: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="system"] #homescreen {
--rocketbar-urlbar-height: 0px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="system"] #homescreen > .chrome {
display: none;
}
/* Gaia finishes opening/closing on animationend: retain that event, skip the effect. */
html[data-vulpes-motion="reduced"][data-vulpes-app="system"] .appWindow,
html[data-vulpes-motion="reduced"][data-vulpes-app="homescreen"] :is(gaia-app-icon, .gaia-container-child, .icon-container, .scrollable, #panels) {
animation-duration: .001s !important;
animation-delay: 0s !important;
transition-duration: .001s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
/* Enabled by the Nokia adapter, independent of the shared compact layout.
* Keep a nonzero duration so Gaia's transitionend navigation still completes. */
html[data-vulpes-motion="reduced"],
html[data-vulpes-motion="reduced"] *,
html[data-vulpes-motion="reduced"] *::before,
html[data-vulpes-motion="reduced"] *::after {
transition-duration: .001s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
html[data-vulpes-motion="reduced"][data-vulpes-layout="compact"][data-vulpes-app="system"]
#screen:has(#homescreen.active):not(.rocketbar-focused):not(.utility-tray):not(.locked) #statusbar {
background: rgba(5, 15, 31, .72) !important;
}
#vulpes-compact-app-name {
display: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #vulpes-compact-app-name {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 52px;
padding: 2px 10px;
color: white;
font: 500 20px/24px FiraSans, sans-serif;
text-align: center;
overflow-wrap: anywhere;
overflow: hidden;
text-shadow: 0 1px 2px #000;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] gaia-app-icon[data-vulpes-compact-selected] {
outline: 2px solid #00aaff !important;
outline-offset: -2px !important;
border-radius: 8px;
}
/* Keep Gaia's dial pad and controls inside a feature-phone viewport. */
@media (max-width: 320px) and (max-height: 400px) {
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #phone-number-view-container {
height: 48px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #phone-number-view-container .phone-number-view-wrapper-input {
margin-bottom: 0;
padding: 0 8px;
min-width: 0;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #phone-number-view {
font-size: 24px !important;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keypad-delete {
margin-bottom: 0;
padding: 0 8px 0 0;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keyboard-container {
height: calc(100% - 48px);
display: flex;
flex-direction: column;
padding: 0 6px;
box-sizing: border-box;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(4, minmax(0, 1fr));
flex: 1;
min-height: 0;
height: auto;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-row { display: contents; }
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
height: auto;
font-size: 30px;
line-height: 1;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key :is(span,em) {
width: auto;
margin: 0;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key em { font-size: 10px; }
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key .sharp { font-size: 30px; line-height: 1; }
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keypad-callbar {
flex: 0 0 40px;
height: 40px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] :is(.keypad-callbar-button,#keypad-callbar-call-action) {
height: 32px;
background-size: 32px;
}
}
#keypad-callbar-call-action[aria-busy="true"] { opacity: .5; }
#vulpes-cover-settings {
display: block !important; height: auto !important; padding: 0 0 1rem;
}
#vulpes-cover-settings h2 { margin: 1rem 0; height: auto; overflow: visible; }
#vulpes-cover-settings label {
display: block; height: auto !important; padding: .4rem 0; overflow: visible;
}
#vulpes-cover-settings label > span {
display: block; margin: 0; width: auto; line-height: 1.5;
white-space: normal; font-size: 1.6rem; overflow: visible;
}
#vulpes-cover-settings :is(input,select) {
position: static; width: 100%; box-sizing: border-box; margin: 0;
}
#vulpes-cover-settings .vulpes-cover-preview {
background: #04060b; padding: 1rem; text-align: center; overflow-wrap: anywhere;
height: auto; font-size: 1.4rem; line-height: 1.5;
}

View file

@ -56,3 +56,213 @@
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();
// Compact is a user preference, with a keypad-device default supplied by the host.
(() => {
const key = 'vulpes.layout';
let mode = 'auto', compactDefault = false, select;
function apply(value) {
mode = ['auto', 'compact', 'standard'].includes(value) ? value : 'auto';
const compact = mode === 'compact' || (mode === 'auto' && compactDefault);
document.documentElement.dataset.vulpesLayout = compact ? 'compact' : 'standard';
if (select) select.value = mode;
// Gaia caches grid coordinates; switching layout also invalidates that cache.
requestAnimationFrame(() => {
const apps = window.appWindow?.apps;
if (!apps?.icons) return;
if (compact) {
window.appWindow.panels.scrollLeft = 0;
window.appWindow.updatePanelIndicator();
}
apps._iconSize = 0;
apps.icons.synchronise();
apps.refreshGridSize();
});
}
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
});
VulpesCompat.call('layout.current', {}).then(value => {
compactDefault = value.compactDefault;
document.documentElement.dataset.vulpesMotion = value.reducedMotion ? 'reduced' : 'standard';
if (value.reducedMotion) {
// Preserve animationend callbacks used by Gaia, and leave busy indicators running.
const shorten = animations => {
for (const animation of animations) {
const timing = animation.effect?.getTiming();
if (timing && Number.isFinite(timing.iterations) && animation.playState !== 'finished')
animation.effect.updateTiming({duration:1, delay:0, endDelay:0});
}
};
document.addEventListener('animationstart', event => shorten(event.target.getAnimations()), true);
shorten(document.getAnimations());
}
apply(value.mode);
}).catch(console.error);
if (location.hostname !== 'settings.localhost') return;
function label() {
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
const labels = fr ? ['Mode compact', 'Automatique', 'Activé', 'Désactivé'] :
['Compact mode', 'Automatic', 'On', 'Off'];
const title = document.getElementById('vulpes-layout-label');
if (title && title.textContent !== labels[0]) title.textContent = labels[0];
if (select) [...select.options].forEach((option, i) => {
if (option.textContent !== labels[i + 1]) option.textContent = labels[i + 1];
});
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || select?.isConnected) return;
const row = document.createElement('li'), title = document.createElement('p');
row.id = 'vulpes-layout-row'; title.id = 'vulpes-layout-label';
const wrapper = document.createElement('span');
wrapper.className = 'button icon icon-dialog';
select = document.createElement('select'); select.id = 'vulpes-layout';
select.setAttribute('aria-labelledby', title.id);
for (const value of ['auto', 'compact', 'standard']) {
const option = document.createElement('option'); option.value = value; select.append(option);
}
select.value = mode;
select.onchange = async () => {
select.disabled = true;
try { await navigator.mozSettings.createLock().set({[key]:select.value}); }
catch (error) { select.value = mode; console.error(error); }
finally { select.disabled = false; }
};
wrapper.append(select); row.append(title, wrapper); list.append(row); label();
}
addEventListener('localized', label);
new MutationObserver(() => { insert(); label(); }).observe(document.documentElement,
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();
// One readable application name above the compact grid, including keypad focus.
(() => {
if (location.hostname !== 'homescreen.localhost') return;
let heading, selected, grid;
const nameObserver = new MutationObserver(updateName);
function updateName() {
if (!heading || !selected) return;
const name = selected._subtitle?.textContent || selected.getAttribute('aria-label') || '';
if (heading.textContent !== name) heading.textContent = name;
}
function choose(icon) {
if (!icon || !grid?.contains(icon)) return;
if (selected !== icon) {
selected?.removeAttribute('data-vulpes-compact-selected');
selected?.removeAttribute('data-vulpes-keypad-focus');
selected = icon;
selected.setAttribute('data-vulpes-compact-selected', '');
nameObserver.disconnect();
if (selected.shadowRoot) nameObserver.observe(selected.shadowRoot,
{childList:true, subtree:true, characterData:true});
}
updateName();
}
function refresh() {
const focus = grid?.querySelector('gaia-app-icon[data-vulpes-keypad-focus]');
choose(focus || (selected?.isConnected && selected) || grid?.querySelector('gaia-app-icon'));
}
function init() {
grid = document.getElementById('apps');
if (!grid) return;
heading = document.createElement('div');
heading.id = 'vulpes-compact-app-name';
heading.setAttribute('aria-live', 'polite');
heading.setAttribute('aria-atomic', 'true');
document.body.insertBefore(heading, document.getElementById('panels'));
new MutationObserver(refresh).observe(grid,
{childList:true, subtree:true, attributes:true, attributeFilter:['data-vulpes-keypad-focus']});
for (const type of ['focus', 'pointerdown', 'pointerover']) grid.addEventListener(type, event => {
choose(event.target.closest('gaia-app-icon'));
}, true);
addEventListener('localized', updateName);
refresh();
}
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', init, {once:true});
else init();
})();
// Cover-screen preferences live in the shared settings store, like appearance.
(() => {
if (location.hostname !== 'settings.localhost') return;
VulpesCompat.call('layout.current', {}).then(async capabilities => {
if (!capabilities.coverScreen) return;
const defaults = {'vulpes.cover.title':'VULPES', 'vulpes.cover.titleColor':'#33a6ff',
'vulpes.cover.batteryColor':'#33a6ff'};
const values = {...defaults}, controls = {};
const colors = ['#33a6ff', '#ffffff', '#4ade80', '#fbbf24', '#f472b6',
'#ef4444', '#f97316', '#a855f7', '#22d3ee', '#14b8a6', '#9ca3af'];
const result = await navigator.mozSettings.createLock().get('*');
for (const key of Object.keys(defaults)) if (typeof result[key] === 'string') values[key] = result[key];
let row, title, previewTitle, previewBattery;
function labels() {
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
if (!row) return;
title.textContent = fr ? 'Écran extérieur' : 'Cover screen';
const texts = fr ? ['Texte', 'Couleur du texte', 'Couleur de la batterie'] :
['Text', 'Text colour', 'Battery colour'];
const names = fr ? ['Bleu', 'Blanc', 'Vert', 'Jaune', 'Rose', 'Rouge', 'Orange', 'Violet', 'Cyan', 'Turquoise', 'Gris'] :
['Blue', 'White', 'Green', 'Yellow', 'Pink', 'Red', 'Orange', 'Purple', 'Cyan', 'Turquoise', 'Grey'];
Object.keys(defaults).forEach((key, index) => {
const control = controls[key];
control.previousElementSibling.textContent = texts[index];
if (control.options) [...control.options].forEach((option, i) => option.textContent = names[i]);
});
previewBattery.textContent = fr ? 'Batterie · aperçu' : 'Battery · preview';
}
function preview() {
previewTitle.textContent = controls['vulpes.cover.title'].value.trim() || 'VULPES';
previewTitle.style.color = controls['vulpes.cover.titleColor'].value;
previewBattery.style.color = controls['vulpes.cover.batteryColor'].value;
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || row?.isConnected) return;
row = document.createElement('li'); row.id = 'vulpes-cover-settings';
title = document.createElement('h2'); row.append(title);
for (const key of Object.keys(defaults)) {
const label = document.createElement('label'), caption = document.createElement('span');
const control = document.createElement(key.endsWith('.title') ? 'input' : 'select');
control.id = key.replaceAll('.', '-'); label.htmlFor = control.id;
if (control.localName === 'input') {control.type = 'text'; control.maxLength = 24;}
else for (const color of colors) {
const option = document.createElement('option'); option.value = color; control.append(option);
}
control.value = values[key];
if (control.localName === 'select' && !colors.includes(values[key])) control.value = defaults[key];
controls[key] = control;
let pending, writes = Promise.resolve();
const save = () => {
clearTimeout(pending);
preview();
const value = control.value.trim() || defaults[key];
writes = writes.then(async () => {
await navigator.mozSettings.createLock().set({[key]:value});
values[key] = value;
}).catch(console.error);
return writes;
};
control.oninput = () => {
preview();
// Keypad edits emit input; closing the lid need not blur the field.
if (control.localName === 'input') {
clearTimeout(pending); pending = setTimeout(save, 250);
}
};
control.onchange = save;
control.onblur = save;
label.append(caption, control); row.append(label);
}
const sample = document.createElement('div'); sample.className = 'vulpes-cover-preview';
previewTitle = document.createElement('div'); previewBattery = document.createElement('div');
sample.append(previewTitle, previewBattery); row.append(sample); list.append(row); labels(); preview();
}
new MutationObserver(insert).observe(document.documentElement, {childList:true, subtree:true});
addEventListener('localized', labels); insert();
}).catch(console.error);
})();

View file

@ -1,4 +1,4 @@
// Tundra's NetworkManager broker, exposed through Gaia's legacy Wi-Fi interface.
// Tundra's native Wi-Fi broker, exposed through Gaia's legacy Wi-Fi interface.
(() => {
if (!window.VulpesNativeWifi || !['system','settings'].includes(location.hostname.split('.')[0])) return;
const {call, request} = VulpesCompat;
@ -7,6 +7,8 @@
connectionInformation:null, macAddress:'',
});
let networks = [], connectionInfo = {};
const securityTypes = value => value === 'open' ? [] :
[['wpa-psk','sae'].includes(value) ? 'WPA-PSK' : 'WPA-EAP'];
function emit(type, values = {}) {
const event = Object.assign(new Event(type), values);
manager.dispatchEvent(event); manager['on' + type]?.(event);
@ -27,6 +29,19 @@
// disconnected must not announce connection information for a missing AP.
if (network) emit('connectioninfoupdate', {network, ...manager.connectionInformation});
}
function applyState(state) {
power(state.enabled === true);
manager.macAddress=state.macAddress || manager.macAddress;
if (state.state) {
connectionInfo=state.connectionInformation || {};
const network=state.state==='connected' && state.network;
update(network ? {
...networks.find(n=>n.ssid===network.ssid),ssid:network.ssid,
security:securityTypes(network.security),
relSignalStrength:network.signal,savedId:network.savedId,connected:true
} : null);
}
}
let scanPending;
function scan() {
if (scanPending) return scanPending;
@ -36,9 +51,10 @@
async function scanNetworks() {
const result = await call('platform.wifi',{operation:'scan'});
power(result.enabled !== false);
manager.macAddress=result.macAddress || manager.macAddress;
connectionInfo=result.connectionInformation || {};
networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal,
security:n.security === 'open' ? [] : [['wpa-psk','sae'].includes(n.security) ? 'WPA-PSK' : 'WPA-EAP'],
security:securityTypes(n.security),
known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId}));
update(networks.find(n => n.connected) || null);
return networks;
@ -46,7 +62,7 @@
manager.getNetworks = () => request(scan());
let knownPending;
manager.getKnownNetworks = () => {
if (!knownPending) knownPending = call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id}))).finally(()=>{knownPending=null;});
if (!knownPending) knownPending = call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:securityTypes(n.security),relSignalStrength:0,known:true,savedId:n.id}))).finally(()=>{knownPending=null;});
return request(knownPending);
};
manager.associate = network => request((async () => {
@ -148,7 +164,7 @@
addEventListener('DOMContentLoaded', async () => {
try {
const state = await call('platform.wifi',{operation:'status'});
power(state.enabled);
applyState(state);
if (location.hostname.startsWith('system.')) {
let queue=Promise.resolve();
navigator.mozSettings.addObserver('wifi.enabled', event => {
@ -158,6 +174,8 @@
try {
const result=await call('platform.wifi',{operation:'setEnabled',enabled});
power(result.enabled,true);
applyState(result);
if(result.enabled) await scan();
if(!result.enabled)await call('settings.set',{values:{'tethering.wifi.enabled':false}});
} catch(error) {
await call('settings.set',{values:{'wifi.enabled':manager.enabled}});
@ -168,20 +186,20 @@
await call('settings.set',{values:{'wifi.enabled':state.enabled}});
} else {
navigator.mozSettings.addObserver('wifi.enabled', () => {
call('platform.wifi',{operation:'status'}).then(s=>power(s.enabled)).catch(console.warn);
call('platform.wifi',{operation:'status'}).then(applyState).catch(console.warn);
});
}
// Settings may receive the preference before the radio operation completes.
// Refresh while visible so its panel also follows hardware power changes.
if(location.hostname.startsWith('settings.')) {
// Settings follows delayed power changes. Adapters with live association
// state also refresh System, so reconnects update the status-bar icon.
if(location.hostname.startsWith('settings.') || state.state) {
let pending=false;
const refresh=setInterval(async()=>{
if(document.hidden || pending) return;
if((location.hostname.startsWith('settings.') && document.hidden) || pending) return;
pending=true;
try {power((await call('platform.wifi',{operation:'status'})).enabled);}
try {applyState(await call('platform.wifi',{operation:'status'}));}
catch(error) {console.warn('Wi-Fi status refresh failed',error);}
finally {pending=false;}
},2000);
},location.hostname.startsWith('settings.') ? 2000 : 5000);
addEventListener('pagehide',()=>clearInterval(refresh),{once:true});
}
if(manager.enabled) await scan();

View file

@ -1,5 +1,20 @@
// Runs only on our remote catalogue. It exposes no privileged API to the page.
// Catalogue controls enter the same local confirmation; no privileged page API.
function installMarketplacePage(win, send) {
if(win.location.origin==='https://ffapps.danielherr.software' && win.location.pathname.startsWith('/app/')) {
const doc=win.document,button=doc.getElementById('install_button');
const request=(element,url)=>element.addEventListener('click',event=>{
if(!event.isTrusted || event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey)return;
event.preventDefault();event.stopImmediatePropagation();send(url.href);
},true);
if(button?.dataset.manifest==='manifest.webapp') {
button.disabled=false;request(button,new URL('manifest.webapp',win.location.href));
}
for(const link of doc.querySelectorAll('a[download][href]')) {
const url=new URL(link.href);
if(url.origin===win.location.origin && url.pathname.endsWith('.zip'))request(link,url);
}
return;
}
if(win.location.origin!=='https://vulpes-os.org' || !win.location.pathname.startsWith('/marketplace/'))return;
const doc=win.document;
for(const link of doc.querySelectorAll('a[href]')) {

View file

@ -68,6 +68,8 @@
'The modem is not ready yet. Try again shortly.'],
RADIO_BUSY: ['Une opération téléphonique est déjà en cours.',
'A phone operation is already in progress.'],
RADIO_TIMEOUT: ['Le modem ne répond pas. Attendez le retour du réseau avant de réessayer.',
'The modem is not responding. Wait for the network to recover before trying again.'],
RADIO_REQUEST_FAILED: ['Le modem a refusé la demande. Vérifiez la SIM et le réseau mobile.',
'The modem rejected the request. Check the SIM and mobile network.'],
INVALID_NUMBER: ['Numéro invalide.', 'Invalid phone number.'],
@ -88,14 +90,25 @@
};
if (app === 'communications') {
// All Gaia call entry points use the same authorized host operation.
window.VulpesPlatformDial = async number => {
try {
return navigator.mozTelephony ? await navigator.mozTelephony.dial(number) :
await call('platform.dial', {number});
} catch (error) {
failure(error);
throw error;
}
let dialing = null;
window.VulpesPlatformDial = number => {
if (dialing) return dialing;
const button = document.getElementById('keypad-callbar-call-action');
const disabled = button?.disabled;
if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
dialing = (async () => {
try {
return navigator.mozTelephony ? await navigator.mozTelephony.dial(number) :
await call('platform.dial', {number});
} catch (error) {
failure(error);
throw error;
} finally {
dialing = null;
if (button) { button.disabled = disabled; button.removeAttribute('aria-busy'); }
}
})();
return dialing;
};
}
function button(id, french, english, action) {

View file

@ -1,4 +1,5 @@
/* Navigation matches the Android preview; Gaia owns the application theme. */
[keypad] #home { display: none; }
#home {
appearance: none;
box-sizing: border-box;

View file

@ -2,6 +2,8 @@
const { Runtime } = ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
const { Views } = ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const fullscreenHost = Services.env.get('VULPES_FULLSCREEN') === '1';
const keypadHost = Services.env.get('VULPES_KEYPAD') === '1';
if (keypadHost) document.documentElement.setAttribute('keypad', '');
if (fullscreenHost) {
// Set native window decoration before the Wayland surface is mapped.
document.documentElement.setAttribute('hidechrome', 'true');
@ -9,6 +11,7 @@ if (fullscreenHost) {
}
window.addEventListener('load', async () => {
try {
if (keypadHost) window.resizeTo(240, 320);
if (fullscreenHost) {
window.fullScreen = true;
let last = '', stableSince = performance.now();
@ -22,6 +25,12 @@ window.addEventListener('load', async () => {
}
window.vulpesStartup = {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,fullscreen:window.fullScreen};
await Runtime.init();
if (keypadHost) ChromeUtils.registerWindowActor('Keypad', {
parent: {esModuleURI:'resource://vulpes/host/actors/KeypadParent.sys.mjs'},
child: {esModuleURI:'resource://vulpes/host/actors/KeypadChild.sys.mjs'},
allFrames:true, safeForUntrustedWebProcess:true,
matches:['http://*/*', 'https://*/*', 'about:blank', 'about:neterror?*', 'about:certerror?*', 'about:httpsonlyerror?*'],
});
Services.ppmm.sharedData.set('vulpes:native-input',Services.env.get('VULPES_TUNDRA')==='1' || Services.env.get('VULPES_TEST_INPUT')==='1');
Services.ppmm.sharedData.flush();
if (Services.env.get('VULPES_TUNDRA') === '1') {
@ -60,10 +69,16 @@ window.addEventListener('load', async () => {
safeForUntrustedWebProcess: true,
matches: Runtime.apps.filter(app=>!app.installed).map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
});
ChromeUtils.registerWindowActor('WebInput', {
parent:{esModuleURI:'resource://vulpes/host/actors/WebInputParent.sys.mjs'},
child:{esModuleURI:'resource://vulpes/host/actors/WebInputChild.sys.mjs',
events:{focusin:{capture:true},pointerdown:{capture:true},mousedown:{capture:true},click:{capture:true}}},
allFrames:true,safeForUntrustedWebProcess:true,matches:['http://*/*','https://*/*'],
});
ChromeUtils.registerWindowActor('VulpesMarketplace', {
parent:{esModuleURI:'resource://vulpes/host/actors/MarketplaceParent.sys.mjs'},
child:{esModuleURI:'resource://vulpes/host/actors/MarketplaceChild.sys.mjs',events:{DOMContentLoaded:{capture:true}}},
allFrames:true,safeForUntrustedWebProcess:true,matches:['https://vulpes-os.org/marketplace/*']
allFrames:true,safeForUntrustedWebProcess:true,matches:['https://vulpes-os.org/marketplace/*','https://ffapps.danielherr.software/app/*']
});
ChromeUtils.registerWindowActor('VulpesTouch', {
child: {
@ -82,6 +97,20 @@ window.addEventListener('load', async () => {
browser.setAttribute('maychangeremoteness', 'true');
browser.style.cssText = 'display:block;width:480px;height:800px';
document.getElementById('screen').append(browser);
if (keypadHost) for (const type of ['keydown','keyup']) window.addEventListener(type, event => {
const direction = event.key === 'AudioVolumeUp' ? 'up' : event.key === 'AudioVolumeDown' ? 'down' : null;
if (!event.isTrusted || !direction) return;
event.preventDefault(); event.stopImmediatePropagation();
if (!event.repeat) Runtime.broadcast('hardware-key', {
type:'volume-'+direction+'-button-'+(type === 'keydown' ? 'press' : 'release')
}, 'system');
}, true);
if (keypadHost) window.addEventListener('keydown', event => {
if (!event.isTrusted || event.ctrlKey || event.altKey || event.metaKey || event.key === 'Shift') return;
event.preventDefault(); event.stopPropagation();
browser.browsingContext.currentWindowGlobal?.getActor('Keypad').sendAsyncMessage('Vulpes:Navigate',
{key:event.key,code:event.code,keyCode:event.keyCode,repeat:event.repeat});
}, true);
// A standalone compositor owns the whole display; desktop windows retain
// their existing phone dimensions unless the launcher requests fullscreen.
{
@ -89,7 +118,7 @@ window.addEventListener('load', async () => {
const homeButton = document.getElementById('home');
const resize = () => {
const landscape = window.innerWidth > window.innerHeight;
const overlay = document.documentElement.hasAttribute('home-active');
const overlay = keypadHost || document.documentElement.hasAttribute('home-active');
const width = Math.max(1, window.innerWidth - (landscape && !overlay ? 44 : 0));
const height = Math.max(1, window.innerHeight - (!landscape && !overlay ? 44 : 0));
screen.style.width = width + 'px';