Release Preview 20 shared desktop, Android and Sargo updates

Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
OverSu 2026-10-13 00:19:45 +02:00
commit 1311bed5e9
170 changed files with 19698 additions and 2495 deletions

30
host/Downloads.sys.mjs Normal file
View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: MPL-2.0
// Download identities belong to Gecko; paths supplied by content are never used.
export class GaiaDownloads {
constructor() {this.ids=new WeakMap();this.items=new Map();}
async request(app, params={}) {
if(!['settings','system'].includes(app.id))throw Error('PERMISSION_DENIED');
if(!['list','pause','resume','remove','blob'].includes(params.operation))throw Error('INVALID_VALUE');
const {Downloads}=ChromeUtils.importESModule('resource://gre/modules/Downloads.sys.mjs');
const list=await Downloads.getList(Downloads.PUBLIC);
const entries=await list.getAll();
this.items.clear();
const rows=entries.map(item=>{
if(!this.ids.has(item))this.ids.set(item,Services.uuid.generateUUID().toString());
const id=this.ids.get(item);this.items.set(id,item);
return {id,url:item.source.url,path:item.target.path,startTime:+item.startTime,
contentType:item.contentType||'application/octet-stream',currentBytes:item.currentBytes,totalBytes:item.totalBytes,
state:item.succeeded?'succeeded':item.stopped?'stopped':'downloading'};
});
if(params.operation==='list')return rows;
const item=this.items.get(params.id);if(!item)throw Error('DOWNLOAD_NOT_FOUND');
if(params.operation==='blob') {
if(!item.succeeded)throw Error('DOWNLOAD_NOT_COMPLETE');
return File.createFromFileName(item.target.path);
}
if(params.operation==='pause')await item.cancel();
if(params.operation==='resume')await item.start();
if(params.operation==='remove') {await item.cancel();await item.finalize(true);if(item.succeeded)await IOUtils.remove(item.target.path,{ignoreAbsent:true});await list.remove(item);}
return true;
}
}

View file

@ -1,36 +1 @@
// Only the bundled keyboard can edit the field reported by a content actor.
export class Input {
constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
changed(actor, state) {
const app=this.runtime.identity(actor);
if (!app || app.id==='keyboard') return;
if (!state) { if(this.focus?.actor===actor) this.hide(); return; }
this.focus={actor,state:{...state,id:++this.sequence}};
this.runtime.broadcast('input-focus',this.focus.state,'system');
this.runtime.broadcast('input-focus',this.focus.state,'keyboard');
}
hide() {
this.focus=null;
this.runtime.broadcast('input-focus',null,'system');
this.runtime.broadcast('input-focus',null,'keyboard');
}
cleanup(actor) { if(this.focus?.actor===actor) this.hide(); }
async request(actor, p) {
const app=this.runtime.identity(actor);
if (!app || !['keyboard','system'].includes(app.id)) throw Error('PERMISSION_DENIED');
if (p.operation==='hide') { this.hide();return null; }
if(app.id!=='keyboard') throw Error('PERMISSION_DENIED');
if(p.operation==='state') return this.focus?.state || null;
if(p.operation==='height') {
if(!Number.isFinite(p.height) || p.height<0 || p.height>600) throw Error('INVALID_VALUE');
this.runtime.broadcast('input-height',{height:p.height},'system');return null;
}
if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
const context=this.focus;
const result=await context.actor.sendQuery('Vulpes:Edit',p);
// An application can close or focus another field while the edit is pending.
if(this.focus!==context) throw Error('INPUT_CONTEXT_EXPIRED');
Object.assign(context.state,result);
return context.state;
}
}
export {Input} from 'resource://vulpes/services/input.mjs';

View file

@ -1,10 +1,11 @@
// Desktop message store. Radio operations fail explicitly; no synthetic SMS.
export class Messages {
constructor(storage, radio = null, notify = () => {}, radioStatus = null) {
constructor(storage, radio = null, notify = () => {}, radioStatus = null, mms = null) {
this.storage = storage;
this.radio = radio;
this.notify = notify;
this.radioStatus = radioStatus;
this.mms = mms;
this.queue = Promise.resolve();
}
receive(incoming) {
@ -14,7 +15,8 @@ export class Messages {
if (existing) return existing;
const id=Math.max(Date.now(),...records.map(m=>Number(m.id)+1 || 0));
const record={id,threadId:records.find(m=>(m.sender||m.receiver)===incoming.sender)?.threadId || id+1,
receipt:incoming.receipt,type:'sms',sender:incoming.sender,receiver:null,
receipt:incoming.receipt,type:incoming.type || 'sms',sender:incoming.sender,receiver:null,
...(incoming.type==='mms' ? {receivers:incoming.receivers || [],subject:incoming.subject || '',smil:incoming.smil || '',attachments:incoming.attachments || [],deliveryInfo:[]} : {}),
body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false};
records.push(record);
await this.storage.run('vulpes-private-messages','put',[records,'records']);
@ -24,14 +26,16 @@ export class Messages {
return work;
}
refreshOutgoing() {
if (!this.radioStatus) return Promise.resolve();
if (!this.radioStatus && !this.mms) return Promise.resolve();
const work=this.queue.then(async()=>{
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
for (const message of records.filter(m=>m.delivery==='sending' && m.radioPath)) {
const result=await this.radioStatus(message.radioPath);
const result=await (message.type==='mms' ? this.mms.status(message.radioPath) : this.radioStatus(message.radioPath));
if (!['sent','failed'].includes(result.state)) continue;
message.delivery=result.state==='sent'?'sent':'error';
message.deliveryStatus=result.state==='sent'?'not-applicable':'error';
if(result.error) message.error=result.error;
if(message.type==='mms') message.deliveryInfo=message.receivers.map(receiver=>({receiver,deliveryStatus:message.deliveryStatus,readStatus:'not-applicable'}));
await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify(result.state,structuredClone(message));
}
@ -104,7 +108,34 @@ export class Messages {
this.notify('sent', structuredClone(record));
return record;
}
if (['send', 'sendMMS', 'retrieveMMS', 'getSmscAddress', 'setSmscAddress'].includes(operation))
if (operation === 'sendMMS' && this.mms) {
const payload=args[0];
this.mms.validate(payload);
const receivers=payload.receivers.slice();
const participants=receivers.slice().sort().join(',');
const existing=records.find(m=>(m.type==='mms' && m.delivery!=='received' ? m.receivers.slice().sort().join(',') : m.receiver || m.sender)===participants);
const record={id:nextId++,threadId:existing?.threadId || nextId++,type:'mms',sender:null,
receivers,subject:payload.subject || '',smil:payload.smil || '',attachments:payload.attachments || [],body:payload.subject || '',
timestamp:Date.now(),delivery:'sending',deliveryStatus:'pending',read:true,
deliveryInfo:receivers.map(receiver=>({receiver,deliveryStatus:'pending',readStatus:'not-applicable'}))};
records.push(record);
await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify('sending',structuredClone(record));
try {
const submitted=await this.mms.send(payload);
record.radioPath=submitted.path;
await this.storage.run('vulpes-private-messages','put',[records,'records']);
return record;
} catch(error) {
record.delivery='error';record.deliveryStatus='error';record.error=error.message;
record.deliveryInfo=receivers.map(receiver=>({receiver,deliveryStatus:'error',readStatus:'not-applicable'}));
await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify('failed',structuredClone(record));throw error;
}
}
if (['sendMMS', 'retrieveMMS'].includes(operation))
throw new Error('NotSupportedError');
if (['send', 'getSmscAddress', 'setSmscAddress'].includes(operation))
throw new Error('RadioDisabledError');
if (operation === 'getThreads') {
const threads = new Map();
@ -115,7 +146,7 @@ export class Messages {
id: message.threadId,
body: message.body,
timestamp: message.timestamp,
participants: [message.sender || message.receiver],
participants: message.delivery==='received' ? [message.sender] : message.type==='mms' ? message.receivers : [message.receiver],
unreadCount: records.filter((m) => m.threadId === message.threadId && !m.read).length,
lastMessageType: message.type,
});

View file

@ -17,8 +17,9 @@ export class NativeCamera {
return await Promise.race([
(async () => {
const pipe=session.process.stdout;
const sequence=metadata ? null : await pipe.readUint32();
const length=await pipe.readUint32();
const header=new DataView(await pipe.read(metadata ? 4 : 8));
const sequence=metadata ? null : header.getUint32(0,true);
const length=header.getUint32(metadata ? 0 : 4,true);
if(metadata) {
if(length>65536) throw Error('CAMERA_INVALID_FORMAT');
return pipe.readJSON(length);

View file

@ -1,9 +1,12 @@
import {receivedMms} from 'resource://vulpes/services/mms.mjs';
import { authorizePlatform } from 'resource://vulpes/services/platform.mjs';
import {
tundraCapabilities,
tundraBluetooth,
tundraCallAudio,
tundraHardware,
tundraRadio,
tundraMms,
tundraSnapshot,
tundraWifi,
} from 'resource://vulpes/host/Tundra.sys.mjs';
@ -48,6 +51,25 @@ export class Platform {
}
async init() {
this.radioQueue = Promise.resolve();
if (Services.env.get('VULPES_TUNDRA') === '1') {
let shown = null;
const pollBluetooth = async () => {
try {
const state = await tundraBluetooth({operation:'status'});
const pending = state.pairing?.[0];
const app = this.runtime.apps.find(a=>a.id==='bluetooth');
if (pending && app && pending.id!==shown) {
shown=pending.id;
this.runtime.broadcast('launch',{manifestURL:app.manifestURL,origin:app.origin,
url:app.origin+'/onpair.html',timestamp:Date.now()},'system');
}
if (!pending) shown=null;
} catch (_) {}
setTimeout(pollBluetooth,1500);
};
pollBluetooth();
}
this.notificationPath = PathUtils.join(PathUtils.profileDir,'vulpes-notifications.json');
let notifications = [];
try { notifications = await IOUtils.readJSON(this.notificationPath); } catch (_) {}
@ -80,6 +102,16 @@ export class Platform {
await tundraRadio('ackMessages',{ids:[row.receipt]});
}
} catch(error) {console.error('SMS inbox:',error.message);}
try {
const rows=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));
await this.gaiaNotification(app,{operation:'show',title:message.sender,body:message.body || 'MMS',tag:'sms-'+message.id,data:{threadId:message.threadId}});
this.runtime.broadcast('sms-received',message,'sms');
await tundraMms('ack',{ids:[row.receipt]});
}
} catch(error) {console.error('MMS inbox:',error.message);}
setTimeout(inbox,2000);
};
inbox();
@ -136,48 +168,39 @@ export class Platform {
!request.secure ||
request.sharingScreen ||
request.sharingAudio ||
request.audioInputDevices?.length ||
!request.videoInputDevices?.length
!(request.videoInputDevices?.length || request.audioInputDevices?.length)
) {
deny();
return;
}
const principal = global.documentPrincipal;
let permission = Services.perms.testExactPermissionFromPrincipal(principal, 'camera');
if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
const allowed = Services.prompt.confirm(
browser.ownerDocument.defaultView,
'Vulpes — Photo',
'Autoriser Photo à utiliser la webcam ? / Allow Camera to use your webcam?',
);
permission = allowed
? Ci.nsIPermissionManager.ALLOW_ACTION
: Ci.nsIPermissionManager.DENY_ACTION;
Services.perms.addFromPrincipal(principal, 'camera', permission);
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?']
]) {
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);
}
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) { deny(); return; }
}
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) {
deny();
return;
}
if (!(await actor.checkOSPermission(true, false, false))) {
deny();
return;
}
// Gecko consumes this one-shot grant when allocating the real device.
// Use its top-level principal, as Firefox's WebRTC permission handler does.
if (!(await actor.checkOSPermission(!!video, !!audio, false))) { deny(); return; }
const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal;
Services.perms.addFromPrincipal(
topPrincipal,
'MediaManagerVideo',
Ci.nsIPermissionManager.ALLOW_ACTION,
Ci.nsIPermissionManager.EXPIRE_SESSION,
);
const device = request.videoInputDevices[0];
actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
const devices = [];
for (const [device, kind] of [[video, 'MediaManagerVideo'], [audio, 'MediaManagerAudio']]) {
if (!device) continue;
Services.perms.addFromPrincipal(topPrincipal, kind, Ci.nsIPermissionManager.ALLOW_ACTION, Ci.nsIPermissionManager.EXPIRE_SESSION);
actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
devices.push(device.deviceIndex);
}
actor.sendAsyncMessage('webrtc:Allow', {
callID: request.callID,
windowID: request.windowID,
devices: [request.videoInputDevices[0].deviceIndex],
devices,
});
}
async request(actor, app, operation, params) {
@ -193,6 +216,9 @@ export class Platform {
if (operation === 'snapshot') return tundraSnapshot();
if (operation === 'power') return tundraHardware({operation:'power', action:params.action});
if (operation === 'wifi') return tundraWifi(params);
if (operation === 'deviceSettings') return tundraRadio('deviceSettings', params);
if (operation === 'mobileSettings') return tundraRadio('mobileSettings', params);
if (operation === 'bluetooth') return tundraBluetooth(params);
if (['callHistory','ackCallHistory'].includes(operation)) return this.radio(operation, params);
if (operation === 'dial') return this.radio('dial', params);
if (operation === 'hangup') return tundraRadio('hangup', params);

View file

@ -1,3 +1,4 @@
import {mmsPayload} from 'resource://vulpes/services/mms.mjs';
import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs';
import {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
@ -13,7 +14,7 @@ import { Messages } from 'resource://vulpes/host/Messages.sys.mjs';
import { Alarms } from 'resource://vulpes/host/Alarms.sys.mjs';
import { Platform } from 'resource://vulpes/host/Platform.sys.mjs';
import { Media } from 'resource://vulpes/host/Media.sys.mjs';
import { tundraCallAudio, tundraHardware, tundraRadio } from 'resource://vulpes/host/Tundra.sys.mjs';
import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs';
Cu.importGlobalProperties(['indexedDB']);
// This is the only privileged owner of application identity and settings data.
@ -111,6 +112,7 @@ export const Runtime = {
}
Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']);
this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps,
choose:data=>this.broadcast('activity-choice',data,'system'),
uuid:()=>Services.uuid.generateUUID().toString(),
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,
@ -123,6 +125,19 @@ export const Runtime = {
const values = { ...this.values, ...Object.fromEntries(entries) };
await IOUtils.writeJSON(path, values, { tmpPath: path + '.tmp' });
this.values = values;
const prefMap={
'ui.click_hold_context_menus.delay':'ui.click_hold_context_menus.delay',
'accessibility.monoaudio.enable':'accessibility.monoaudio.enable',
'geolocation.enabled':'geo.enabled',
'privacy.donottrackheader.enabled':'privacy.donottrackheader.enabled',
'debug.paint-flashing.enabled':'nglayout.debug.paint_flashing',
'apz.overscroll.enabled':'apz.overscroll.enabled'
};
for(const [key,value] of entries) {
const pref=prefMap[key];if(!pref)continue;
if(typeof value==='boolean')Services.prefs.setBoolPref(pref,value);
else if(key==='ui.click_hold_context_menus.delay' && [400,800,1600].includes(value))Services.prefs.setIntPref(pref,value);
}
if (entries.some(([key]) => key === 'language.current'))
Services.prefs.setStringPref('intl.accept_languages', values['language.current']);
if (Services.env.get('VULPES_TUNDRA') === '1') {
@ -156,6 +171,8 @@ 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,
send:message=>tundraMms('send',mmsPayload(message)), status:path=>tundraMms('state',{path})} : null,
);
this.media = new Media((data) => this.broadcast('mediachange', data));
this.platform = new Platform(this);
@ -301,9 +318,10 @@ export const Runtime = {
}
}
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
if (['activities.subscribe','activities.finish','activities.cancel'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
if (['activities.subscribe','activities.finish','activities.cancel','activities.select'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
try {
const result = method === 'activities.cancel' ? this.activities.cancel(actor)
const result = method === 'activities.select' ? this.activities.select(actor,params)
: method === 'activities.cancel' ? this.activities.cancel(actor)
: method === 'activities.subscribe' ? this.activities.subscribe(actor)
: method === 'activities.finish' ? this.activities.finish(actor,params)
: await this.activities.start(actor,params);
@ -359,11 +377,28 @@ export const Runtime = {
return { error: { code: error.message || 'VIEW_ERROR' } };
}
}
if (method === 'storage.stats') {
try {const {storageStats}=ChromeUtils.importESModule('resource://vulpes/host/StorageStats.sys.mjs');return {result:await storageStats(app)};}
catch(error) {return {error:{code:error.message || 'STORAGE_ERROR'}};}
}
if (method === 'downloads.request') {
try {
const {GaiaDownloads}=ChromeUtils.importESModule('resource://vulpes/host/Downloads.sys.mjs');
this.downloads ||= new GaiaDownloads();
return {result:await this.downloads.request(app,params)};
} catch(error) {return {error:{code:error.message || 'DOWNLOAD_ERROR'}};}
}
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')
return this.grants(app).includes('settings.read')
? { result: { ...this.values } }
: { error: { code: 'PERMISSION_DENIED' } };
if (method === 'settings.clockChanged') {
if(app.id !== 'settings' || Services.env.get('VULPES_TUNDRA') !== '1') return {error:{code:'PERMISSION_DENIED'}};
Services.obs.notifyObservers(null, 'system-timezone-changed');
return {result:true};
}
if (method.startsWith('settings.'))
return this.settings.bindSession(app.id, this.grants(app))(request);
if (method === 'desktop.idle') {
@ -517,7 +552,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
if (type === 'settingchange' && data.key !== 'language.current' && !this.grants(app).includes('settings.read')) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
if (
type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')

133
host/SettingsAudit.sys.mjs Normal file
View file

@ -0,0 +1,133 @@
// SPDX-License-Identifier: MPL-2.0
// Settings navigation and bounded readback tests, enabled by a private marker.
export async function auditSettings({evaluate,wait,sleep,save}) {
const context=new Set(['wifi-status','wifi-auth','wifi-joinHidden','wifi-wps','wifi-enterCertificateNickname','call-cf-not-reachable-settings','call-cf-no-reply-settings','call-cf-mobile-busy-settings','call-cf-unconditional-settings','call-voiceMailSettings','hotspot-wifiSettings','screenLock-passcode','apn-editor','apn-list','appPermissions-details','addon-details','homescreen-details','frame','simpin-dialog','call-fdnList-add','call-barring-passcode-change','settings-alert-dialog','settings-confirm-dialog','settings-prompt-dialog','ums-partial-warning','template-messaging','full-developer-mode-warning','full-developer-mode-final-warning']);
await evaluate('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
await wait('settings','return document.readyState==="complete" && typeof require==="function";');
const ids=await evaluate('settings','return Array.from(document.querySelectorAll("section[id]"),e=>e.id);');
const report={scope:'navigation only; no destructive actions, SIM PIN attempts or subscriptions',panels:{}};
for(const id of ids) {
if(context.has(id)){report.panels[id]={contextRequired:true};continue;}
try {
await evaluate('settings',`window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate(${JSON.stringify(id)},${JSON.stringify(id==='messaging-details'?{cardIndex:0}:{})}));return true;`);
await wait('settings',`return document.querySelector('section.current')?.id===${JSON.stringify(id)};`,25000);
// Slow carrier queries continue after the panel appears. Wait for those
// controls before leaving, rather than flooding the radio worker while
// sweeping every panel in a few milliseconds.
if(id==='call')await wait('settings',"return !document.querySelector('#ril-callerId').disabled;",30000);
if(id==='call-forwarding')await wait('settings',"return !!document.querySelector('#call-forwarding .callForwardingUnconditional[aria-disabled=false]');",30000);
await sleep(450);
report.panels[id]=await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});return {opened:true,title:p.querySelector('h1')?.textContent,unavailable:!!p.querySelector('[data-vulpes-unavailable]'),links:Array.from(p.querySelectorAll('a[href^="#"]'),e=>e.getAttribute('href')),controls:Array.from(p.querySelectorAll('input,select,gaia-switch,gaia-checkbox'),e=>({name:e.name||e.id||e.getAttribute('name'),disabled:!!e.disabled,hidden:!e.checkVisibility()})),errors:window.__vulpesDiagnostics};`);
} catch(error) {
report.panels[id]={opened:false,error:String(error),errors:await evaluate('settings','return window.__vulpesDiagnostics;')};
await evaluate('settings','location.reload();return true;');await sleep(1500);
await wait('settings','return document.readyState==="complete" && typeof require==="function";');
}
await save(report);
}
report.dialogs={};
for(const [id,method,options] of [
['settings-confirm-dialog','confirm',{}],['settings-prompt-dialog','prompt',{}],['settings-alert-dialog','alert',{}],
['wifi-auth','show',{network:{ssid:'Audit — not connected',security:['WPA-PSK']},security:'WPA-PSK',sl:3}],
['wifi-status','show',{network:{ssid:'Audit — not connected',security:[]},security:'',sl:3}],
['wifi-joinHidden','show',{}],['screenLock-passcode','show',{mode:'create'}]
]) {
try {
await evaluate('settings',`window.__dialogResult=null;window.__vulpesDiagnostics=[];require(['modules/dialog_service'],d=>d.${method}(${JSON.stringify(method==='show'?id:'cancel')},${JSON.stringify(options)}).then(r=>window.__dialogResult=r,e=>window.__dialogResult={error:String(e)}));return true;`);
await wait('settings',`return document.getElementById(${JSON.stringify(id)}).classList.contains('current');`,3000);
await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});const button=p.querySelector('button[type=reset]')||p.querySelector('button[type=submit]');if(button)button.click();else p.querySelector('gaia-header')?.dispatchEvent(new CustomEvent('action'));return true;`);
await wait('settings',`return !!window.__dialogResult;`,3000);
report.dialogs[id]=await evaluate('settings',`return {result:window.__dialogResult,errors:window.__vulpesDiagnostics,closed:!document.getElementById(${JSON.stringify(id)}).classList.contains('current')};`);
} catch(error) {
report.dialogs[id]={error:String(error),errors:await evaluate('settings','return window.__vulpesDiagnostics;')};
await evaluate('settings','location.reload();return true;');await sleep(1500);
await wait('settings','return document.readyState==="complete" && typeof require==="function";');
}
await save(report);
}
report.effects={};
for(const [name,code] of Object.entries({
storage:'return await VulpesCompat.call("storage.stats",{});',
modemReadback:'const read=await VulpesCompat.call("platform.mobileSettings",{action:"network"});if(typeof read.enabled!=="boolean")throw Error("Unknown data state");const after=await VulpesCompat.call("platform.mobileSettings",{action:"dataEnabled",enabled:read.enabled});return {same:read.enabled===after.enabled,registered:after.status,technology:after.technology};',
clockReadback:'const read=await VulpesCompat.call("platform.deviceSettings",{action:"clock"});const after=await VulpesCompat.call("platform.deviceSettings",{action:"setTimezone",timezone:read.timezone});return {same:read.timezone===after.timezone,timezone:after.timezone,automaticAvailable:after.automaticAvailable};'
})) {
try {report.effects[name]=await evaluate('settings',code);}
catch(error){report.effects[name]={error:String(error)};}
}
report.networkUi={};
try {
await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('operator-settings'));return true;");
await wait('settings',"return document.querySelector('#operator-settings.current select:not([disabled])')?.options.length>0;",7000);
report.networkUi.radio=await evaluate('settings',"const s=document.querySelector('#operator-settings select');s.dispatchEvent(new Event('change',{bubbles:true}));return {mode:s.value,choices:Array.from(s.options,o=>o.value)};");
await wait('settings',"return !document.querySelector('#operator-settings select').disabled;",7000);
report.networkUi.radio.same=await evaluate('settings',`return document.querySelector('#operator-settings select').value===${JSON.stringify(report.networkUi.radio.mode)};`);
for(const type of ['default','mms']) {
await evaluate('settings',`require(['modules/settings_service'],s=>s.navigate('apn-settings'));return true;`);
await wait('settings',"return !!document.querySelector('#apn-settings.current [data-apn-type]');",4000);
await evaluate('settings',`document.querySelector('#apn-settings [data-apn-type=${type}]').click();return true;`);
await wait('settings',"return !!document.querySelector('#apn-list.current label.name');",7000);
await evaluate('settings',"document.querySelector('#apn-list label.name').dispatchEvent(new MouseEvent('mouseup',{bubbles:true}));return true;");
await wait('settings',"return !!document.querySelector('#apn-editor.current .apn:not([disabled])');",7000);
report.networkUi[type]=await evaluate('settings',"const p=document.querySelector('#apn-editor');return {opened:true,apnPopulated:!!p.querySelector('.apn').value,passwordBlank:p.querySelector('.password').value==='',protocol:p.querySelector('.protocol').value,authentication:p.querySelector('.authtype').value,mmscEditable:!p.querySelector('.mmsc').disabled};");
// Submit the current APN unchanged, through Gaia. Never change operator
// credentials, activate a bearer or expose any APN/user/password in logs.
await evaluate('settings',"const p=document.querySelector('#apn-editor');p.querySelector('.apn').dispatchEvent(new Event('input',{bubbles:true}));p.querySelector('.ok').click();return true;");
await wait('settings',"return !!document.querySelector('#apn-list.current label.name');",7000);
report.networkUi[type].savedUnchanged=true;
}
report.networkUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
} catch(error) {report.networkUi.error=String(error);report.networkUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');}
report.supplementaryUi={};
try {
await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('call'));return true;");
await wait('settings',"return !!document.querySelector('#call.current #ril-callerId:not([disabled])');",30000);
report.supplementaryUi.calls=await evaluate('settings',"return {waitingEnabled:!document.querySelector('#menuItem-callWaiting gaia-switch').disabled,callerEnabled:!document.querySelector('#ril-callerId').disabled};");
await evaluate('settings',"document.querySelector('#ril-callerId').dispatchEvent(new Event('change',{bubbles:true}));return true;");
await wait('settings',"return !document.querySelector('#ril-callerId').disabled;",50000);
report.supplementaryUi.callerSavedUnchanged=true;
await evaluate('settings',"require(['modules/settings_service'],s=>s.navigate('call-forwarding'));return true;");
await wait('settings',"return !!document.querySelector('#call-forwarding.current .callForwardingUnconditional[aria-disabled=false]');",30000);
report.supplementaryUi.dialogs={};
for(const [selector,id] of [
['.callForwardingUnconditional','call-cf-unconditional-settings'],['.callForwardingMobileBusy','call-cf-mobile-busy-settings'],
['.callForwardingNoReply','call-cf-no-reply-settings'],['.callForwardingNotReachable','call-cf-not-reachable-settings']
]) {
await evaluate('settings',`document.querySelector('#call-forwarding ${selector}').click();return true;`);
await wait('settings',`return document.getElementById('${id}').classList.contains('current');`,4000);
await evaluate('settings',`document.querySelector('#${id} button[type=reset]').click();return true;`);
await wait('settings',"return document.getElementById('call-forwarding').classList.contains('current');",4000);
report.supplementaryUi.dialogs[id]={opened:true,cancelled:true};
}
report.supplementaryUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
} catch(error) {report.supplementaryUi.error=String(error);report.supplementaryUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');}
report.hotspotUi={};
try {
await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('hotspot'));return true;");
await wait('settings',"return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])');",10000);
await evaluate('settings',"document.querySelector('#hotspot-settings-section a').click();return true;");
await wait('settings',"return document.getElementById('hotspot-wifiSettings').classList.contains('current');",4000);
report.hotspotUi=await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');return {opened:true,securityChoices:p.querySelector('select').options.length,passwordMasked:p.querySelector('input[name=password]').type==='password'};");
await evaluate('settings',"document.querySelector('#hotspot-wifiSettings button[type=reset]').click();return true;");
await wait('settings',"return document.getElementById('hotspot').classList.contains('current') && !document.getElementById('hotspot-wifiSettings').classList.contains('current') && !document.querySelector('#tethering-wifi-enabled').disabled;",4000);
report.hotspotUi.cancelled=true;
const unconfigured=await evaluate('settings',"const s=await VulpesCompat.call('platform.wifi',{operation:'hotspotStatus'});return !s.configured && !s.enabled;");
if(unconfigured) {
await evaluate('settings',"document.querySelector('#hotspot-settings-section a').click();return true;");
await wait('settings',"return document.getElementById('hotspot-wifiSettings').classList.contains('current');",4000);
await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');p.querySelector('input[type=text]').value='Vulpes-qualification';p.querySelector('select').value='wpa2-psk';p.querySelector('input[name=password]').value=crypto.randomUUID();p.querySelector('button[type=submit]').click();return true;");
await wait('settings',"return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])') && !document.getElementById('hotspot-wifiSettings').classList.contains('current');",10000);
report.hotspotUi.configuredByAudit=true;
for(const enabled of [true,false]) {
await evaluate('settings',`const c=document.querySelector('#tethering-wifi-enabled');c.checked=${enabled};c.dispatchEvent(new Event('change',{bubbles:true}));return true;`);
await wait('settings',`const c=document.querySelector('#tethering-wifi-enabled');return !c.disabled && c.checked===${enabled};`,90000);
const actual=await evaluate('settings',"return (await VulpesCompat.call('platform.wifi',{operation:'hotspotStatus'})).enabled;");
if(actual!==enabled)throw Error('Hotspot UI/readback mismatch');
report.hotspotUi[enabled?'started':'stopped']=true;
}
}
report.hotspotUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
} catch(error) {report.hotspotUi.error=String(error);report.hotspotUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');report.hotspotUi.formState=await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');return {current:p.classList.contains('current'),message:p.querySelector('[role=status]')?.textContent,submitDisabled:p.querySelector('button[type=submit]')?.disabled};");}
report.scope='panel navigation plus storage read and idempotent data/timezone/radio/APN write/readback; temporary hotspot start/stop when initially unconfigured; no destructive actions, PIN attempts or subscriptions';
await evaluate('settings','require(["modules/settings_service"],s=>s.navigate("root"));return true;');
return report;
}

View file

@ -0,0 +1,56 @@
// Private qualification entry point, invoked only by a test marker/profile.
export async function auditFollowup({evaluate,wait,sleep,save,tap}) {
const report={checks:{},errors:{},mediaRetained:false};
const launch=async app=>{
await evaluate('system',`ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
await wait(app,'return document.readyState==="complete" && !document.hidden;');
await wait('system',`return Service.query('getActiveApp')?.origin==='http://${app}.localhost:8765';`);
};
await wait('system',`return document.body?.getAttribute('ready-state')==='fullyLoaded';`,30000);
await sleep(1000);
try {
await launch('settings');await wait('settings','return typeof require==="function";');
await evaluate('settings',`const s=await new Promise(r=>require(['modules/settings_service'],r));await s.navigate('dateTime');return true;`);
await sleep(800);
// Test the actual Gaia picker without changing the user's system clock.
await evaluate('settings',`if(!navigator.mozTime){window.__testClockInstalled=true;Object.defineProperty(navigator,'mozTime',{value:{},configurable:true});}window.__dateModule=await new Promise(r=>require(['modules/date_time'],r));window.__oldMozTime=__dateModule._mozTime;window.__pickedTime=null;__dateModule._mozTime={set:d=>{window.__pickedTime=+d;return Promise.resolve();}};document.querySelector('#dateTime .time-manual').classList.remove('disabled');for(const e of document.querySelectorAll('#dateTime input'))e.disabled=false;return true;`);
for(const type of ['date','time']) {
await evaluate('settings',`const e=document.querySelector('#dateTime .${type}-picker');e.disabled=false;e.click();return true;`);
await wait('system',`const p=Service.query('getActiveApp')?.valueSelector;return !!(p?.isShown && p._${type}Picker);`);
report.checks[type+'Picker']=await evaluate('system',`const p=Service.query('getActiveApp').valueSelector;const e=p.elements.${type==='date'?'spinDate':'time'}PickerPopup;return {visible:e.checkVisibility(),height:e.getBoundingClientRect().height};`);
await evaluate('system',`Service.query('getActiveApp').valueSelector.confirm();return true;`);
await wait('settings','return Number.isFinite(window.__pickedTime);');
report.checks[type+'Commit']=true;
await evaluate('settings','window.__pickedTime=null;return true;');
}
await evaluate('settings',`__dateModule._mozTime=__oldMozTime;const s=await new Promise(r=>require(['modules/settings_service'],r));await s.navigate('keyboard');return true;`);
await launch('keyboard');
await wait('keyboard','return !!document.querySelector("#general gaia-header");');
await evaluate('keyboard',`document.querySelector('#general gaia-header').dispatchEvent(new CustomEvent('action'));return true;`);
await wait('system',`return Service.query('getActiveApp')?.origin==='http://settings.localhost:8765';`);
report.checks.keyboardBack=true;
} catch(error){report.errors.settings=String(error);}
finally {
await evaluate('settings','if(window.__dateModule)__dateModule._mozTime=window.__oldMozTime;if(window.__testClockInstalled)delete navigator.mozTime;return true;').catch(()=>{});
}
await save(report);
try {
await launch('camera');
await wait('camera','return !!window.app?.camera?.mozCamera && !app.camera.isBusy;');
await evaluate('camera',`window.__vulpesDiagnostics=[];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 tap('camera','.js-capture');
await wait('camera','return app.camera.get("recording")==="started";');
await sleep(3000);
await tap('camera','.js-capture');
await wait('camera','return !!window.__lastVideo?.blob;',20000);
report.checks.video=await evaluate('camera',`const v=window.__lastVideo;const el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);try{await new Promise((ok,fail)=>{el.onloadeddata=ok;el.onerror=()=>fail(Error('Recorded video cannot be decoded'));});return {bytes:v.blob.size,mime:v.blob.type,path:v.filepath,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}finally{URL.revokeObjectURL(el.src);el.remove();}`);
report.errors.camera=await evaluate('camera','return __vulpesDiagnostics.filter(x=>x.kind==="error" || x.kind==="rejection");');
} catch(error){report.errors.video=String(error);report.errors.camera=await evaluate('camera','return window.__vulpesDiagnostics;').catch(()=>[]);}
finally {
await evaluate('camera',`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(()=>{});
await evaluate('system',"dispatchEvent(new Event('home'));return true;").catch(()=>{});
}
report.passed=!Object.values(report.errors).some(x=>Array.isArray(x)?x.length:!!x) && !!report.checks.video;
await save(report);return report;
}

28
host/StorageStats.sys.mjs Normal file
View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: MPL-2.0
// Only byte counts for Vulpes-owned storage cross the application boundary.
let pending, cached;
export async function storageStats(app) {
if(app.id!=='settings')throw Error('PERMISSION_DENIED');
if(cached && Date.now()-cached.at<5000)return cached.value;
if(pending)return pending;
pending=(async()=>{
async function bytes(path,skip='') {
if(path===skip || !await IOUtils.exists(path))return 0;
const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(path);
if(file.isSymlink())return 0;
const info=await IOUtils.stat(path);
if(info.type==='regular')return info.size;
if(info.type!=='directory')return 0;
let total=0;
for(const child of await IOUtils.getChildren(path)) {
try {total+=await bytes(child,skip);} catch(error) {if(error.name!=='NotFoundError')throw error;}
}
return total;
}
const profile=PathUtils.profileDir,media=PathUtils.join(profile,'vulpes-media');
const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(profile);
const value={free:file.diskSpaceAvailable,apps:await bytes(profile,media),sdcard:await bytes(media),scope:'vulpes-profile'};
cached={at:Date.now(),value};return value;
})().finally(()=>{pending=null;});
return pending;
}

View file

@ -42,6 +42,7 @@ export function tundraCapabilities(release) {
dial: radio,
callControl: radio,
sms: radio,
mms: radio,
alarms: false,
hardwareStatus: true,
wifiControl: Services.env?.get('VULPES_TUNDRA_WIFI') === '1',
@ -50,7 +51,7 @@ export function tundraCapabilities(release) {
export async function tundraRadio(operation, params = {}) {
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
if (!['status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
if (!['deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({
command: '/usr/bin/python3',
@ -78,8 +79,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_REQUEST_FAILED', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
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');
}
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
@ -92,11 +93,11 @@ const WIFI_SCAN_CACHE_MS = 5000;
export async function tundraWifi(params) {
if (Services.env.get('VULPES_TUNDRA_WIFI') !== '1') throw Error('NOT_SUPPORTED');
if (!['scan', 'connect', 'disconnect', 'status', 'setEnabled', 'known', 'forget'].includes(params.operation))
if (!['scan', 'connect', 'connectHidden', 'disconnect', 'status', 'setEnabled', 'known', 'forget','hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation))
throw Error('WIFI_INVALID_REQUEST');
if (params.operation !== 'scan') {
try { return await wifiCommand(params); }
finally { wifiScanCache = null; }
finally { if (['connect','connectHidden','disconnect','forget','setEnabled','hotspotPower'].includes(params.operation)) wifiScanCache = null; }
}
// System and Settings share a broker: at most one Python scan client exists.
if (wifiScanPending) return wifiScanPending;
@ -176,3 +177,33 @@ export async function tundraCallAudio(params) {
if (reply.error) throw Error(reply.error);
return reply.result;
}
// Separate socket and bounded payload so MMS traffic cannot block call control.
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');
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();
let output='';for(;;){const chunk=await process.stdout.readString();if(!chunk)break;output+=chunk;if(output.length>1228800){process.kill();throw Error('MMS_TOO_LARGE');}}
const {exitCode}=await process.wait();if(exitCode)throw Error('MMS_UNAVAILABLE');
const reply=JSON.parse(output);if(reply.ok!==true)throw Error(reply.error || 'MMS_UNAVAILABLE');return reply.result;
}
// 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');
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'});
await process.stdin.write(JSON.stringify({...params,version:1}));await process.stdin.close();
let output='';
for (;;) {
const chunk=await process.stdout.readString();if(!chunk)break;
output+=chunk;if(output.length>65536){process.kill();throw Error('BLUETOOTH_INVALID_REPLY');}
}
const {exitCode}=await process.wait();if(exitCode)throw Error('BLUETOOTH_UNAVAILABLE');
const reply=JSON.parse(output);if(reply.error)throw Error(reply.error);
return reply.result;
}

View file

@ -1,16 +1,20 @@
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
export class VulpesChild extends JSWindowActorChild {
handleEvent(event) {
if(['focusin','pointerdown'].includes(event.type)) {
if(this.editing) return;
if(['focusin','pointerdown','click'].includes(event.type)) {
if(this._editor?.editing) return;
if(!Services.cpmm.sharedData.get('vulpes:native-input')) return;
let element=event.target;
if(element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
const editable=element?.isContentEditable || (['input','textarea'].includes(element?.localName) && !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
if(!editable || element.disabled || element.readOnly) {
if(event.type==='pointerdown') this.sendAsyncMessage('Vulpes:InputFocus',null);
// Dismissing on pointerdown moves buttons before pointerup and loses
// the click. Composer actions may also explicitly retain the editor.
if(event.type==='click' && !element?.closest?.('[data-vulpes-keep-input]'))
this.sendAsyncMessage('Vulpes:InputFocus',null);
return;
}
this.inputElement=element;
this.editor.focus(element);
this.sendAsyncMessage('Vulpes:InputFocus',this.inputState());
return;
}
@ -76,69 +80,9 @@ export class VulpesChild extends JSWindowActorChild {
{ defineAs: 'vulpesFrameForContext' },
);
}
inputState() {
const e=this.inputElement;
const text=e.isContentEditable ? e.textContent : e.value;
const selection=this.contentWindow.getSelection();
let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start;
if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) {
const range=this.document.createRange();range.selectNodeContents(e);
range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length;
range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length;
[start,end]=[Math.min(start,end),Math.max(start,end)];
}
return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'),
inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''),
text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end};
}
edit(p) {
const e=this.inputElement,win=this.contentWindow;
if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED');
this.editing=true;
try {e.focus();} finally {this.editing=false;}
// A touch can focus a new contenteditable while Gecko retains the previous
// field's selection. execCommand follows that selection, not activeElement.
if(e.isContentEditable) {
const selection=win.getSelection();
if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) {
const range=this.document.createRange();
range.selectNodeContents(e);range.collapse(false);
selection.removeAllRanges();selection.addRange(range);
}
}
if(p.operation==='key') {
let key=typeof p.key==='object'?p.key.key:p.key;
if(key===8 || key==='Backspace') this.document.execCommand('delete');
else if(key===13 || key==='Enter') {
const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true};
const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options));
if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak');
e.dispatchEvent(new win.KeyboardEvent('keyup',options));
} else {
const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
if(text.length>16) throw Error('INVALID_VALUE');
this.document.execCommand('insertText',false,text);
}
} else if(['selection','replace'].includes(p.operation)) {
const state=this.inputState();
const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0);
const length=p.length||0;
if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE');
if(e.isContentEditable) {
const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT);
const points=[];let offset=0,node;
while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;}
const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];};
const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length));
win.getSelection().removeAllRanges();win.getSelection().addRange(range);
} else e.setSelectionRange(start,start+length);
if(p.operation==='replace') {
if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE');
this.document.execCommand('insertText',false,p.text);
}
} else throw Error('METHOD_NOT_SUPPORTED');
return this.inputState();
}
get editor() { return this._editor ||= new InputEditor(this.contentWindow); }
inputState() { return this.editor.state(); }
edit(params) { return this.editor.edit(params); }
receiveMessage(message) {
if(message.name==='Vulpes:Edit') return this.edit(message.data);
if (message.name === 'Vulpes:ViewState') {
@ -146,9 +90,16 @@ export class VulpesChild extends JSWindowActorChild {
f => f.browsingContext?.id === message.data.contextId);
if (!frame?.isConnected) return {visible:false};
const win = this.contentWindow, rect = frame.getBoundingClientRect();
// App chrome (the status bar and browser controls) legitimately covers
// the corners of a mozbrowser iframe. Requiring every corner to hit the
// iframe hid the external browser completely, leaving a white page for
// sites such as YouTube. The centre plus one side sample is sufficient
// to prove that the content viewport is visible.
const samples = [[.5,.5],[.05,.5],[.95,.5],[.5,.05],[.5,.95]];
const hits = samples.reduce((count,[x,y]) => count +
(this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame ? 1 : 0), 0);
const visible = !this.document.hidden && frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
[[.5,.5],[.05,.05],[.95,.05],[.05,.95],[.95,.95]].every(([x,y]) =>
this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame);
hits >= 2;
let top = Math.max(0,rect.top);
// Gaia's status bar belongs above all app content, including native web views.
if (this.document.location.hostname === 'system.localhost') {

View file

@ -146,6 +146,12 @@ h1, h2, h3, h4, h5, h6, #statusbar-time,
/* Gaia owns edge gestures; avoid a second host overscroll effect. */
html[data-vulpes-app] { overscroll-behavior:none; }
/* padding-box is no longer supported. Content-box made the full-width
section wrap below the dialog's spacer, hiding its title and message. */
form[role='dialog'][data-type='confirm'] > section {
box-sizing: border-box;
}
/* Native selects now paint their own arrow; the old Gecko arrow-hiding offset
clips Fira descenders on current Gecko. */
@ -200,3 +206,47 @@ html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-acti
/* Homescreen pages must settle at their boundary after a drag (Forgejo #3). */
html[data-vulpes-app="homescreen"] #panels { scroll-snap-type:x mandatory; }
html[data-vulpes-app="homescreen"] #panels > * { scroll-snap-align:start; }
/* System gestures are handled by Gaia, not by the page's pan/zoom handler.
App frames keep their own scrolling; the notification list scrolls vertically. */
html[data-vulpes-app='system'],
html[data-vulpes-app='system'] body {
overscroll-behavior: none;
}
html[data-vulpes-app='system'] #lockscreen {
touch-action: none;
}
html[data-vulpes-app='system'] #utility-tray {
touch-action: pan-y;
overscroll-behavior: contain;
}
/* Keep the original Gaia value selector inside the application's viewport. */
html[data-vulpes-app] #vulpes-value-selector {
position: fixed; inset: 0; z-index: 2147483000; margin: 0;
font-family: FiraSans, sans-serif; box-sizing: border-box;
}
html[data-vulpes-app] #vulpes-value-selector > section { box-sizing: border-box; }
html[data-vulpes-app] #vulpes-value-selector [role=option][aria-disabled=true] { opacity: .45; }
html[data-vulpes-app] #vulpes-value-selector-layer {
position: fixed; inset: 0; z-index: 2147483000;
}
html[data-vulpes-app] :is(.button,.bb-button) select.vulpes-select-label-owner {
opacity: 0;
}
html[data-vulpes-app] .vulpes-select-label {
position: absolute;
inset: 0 3.5rem 0 1.1rem;
display: flex;
align-items: center;
pointer-events: none;
font: italic 1.7rem/1.5 FiraSans, sans-serif;
white-space: nowrap;
overflow: hidden;
color: var(--text-color, #333);
text-shadow: none;
}
html[dir=rtl][data-vulpes-app] .vulpes-select-label { inset: 0 1.1rem 0 3.5rem; }
html[data-vulpes-theme=dark] .vulpes-select-label { color: #eee; }

126
host/gaia-bluetooth.js Normal file
View file

@ -0,0 +1,126 @@
// Gaia Bluetooth v2, backed by the host's permission-checked BlueZ broker.
(() => {
const app = location.hostname.split('.')[0];
if (!(window.VulpesPlatform === 'tundra' || window.VulpesTestBluetooth) ||
!['system', 'settings', 'bluetooth'].includes(app)) return;
const {call} = VulpesCompat;
const manager = Object.assign(new EventTarget(), {defaultAdapter:null});
const adapter = Object.assign(new EventTarget(), {
state:'disabled', address:'', name:'', discoverable:false, discovering:false,
pairingReqs:new EventTarget(),
});
const devices = new Map();
let discovery = null, seen = new Set(), pending = false, timer, closed = false;
let currentPairing = null, initialized = false;
const emit = (target, type, values={}) => {
const event = Object.assign(new Event(type), values);
target.dispatchEvent(event); target['on'+type]?.(event);
};
function change(target, fields) {
const attrs = Object.keys(fields).filter(k=>JSON.stringify(target[k])!==JSON.stringify(fields[k]));
Object.assign(target, fields);
if (attrs.length) emit(target, 'attributechanged', {attrs});
}
function apply(state) {
if (!state?.available) return;
for (const row of state.devices) {
let device = devices.get(row.address);
const oldPaired = device?.paired;
if (!device) {
device = new EventTarget(); devices.set(row.address, device);
}
change(device, {...row, connectionStatus:row.connected?'connected':'disconnected'});
if (oldPaired !== undefined && oldPaired !== row.paired)
emit(adapter, row.paired?'devicepaired':'deviceunpaired', {device,address:row.address});
if (discovery && state.discovering && !seen.has(row.address)) {
seen.add(row.address); emit(discovery, 'devicefound', {device});
}
}
for (const [address, device] of devices) {
if (!state.devices.some(d=>d.address===address)) {
if (device.paired) emit(adapter,'deviceunpaired',{address,device});
devices.delete(address);
}
}
change(adapter, {state:state.enabled?'enabled':'disabled',address:state.address,
name:state.name,discoverable:state.discoverable,discovering:state.discovering});
if (!manager.defaultAdapter) {
manager.defaultAdapter=adapter; emit(manager,'attributechanged',{attrs:['defaultAdapter']});
}
currentPairing = state.pairing?.[0] || null;
if (app==='bluetooth' && location.pathname==='/onpair.html') showPairing();
}
async function command(operation, params={}) {
const value=await call('platform.bluetooth',{operation,...params});
if (value?.available) apply(value);
return value;
}
async function refresh() {
if (pending || closed) return;
pending=true;
try {apply(await command('status'));}
catch(error) {console.warn('Bluetooth:',error.message);}
finally {pending=false;}
}
adapter.enable=()=>command('power',{enabled:true});
adapter.disable=()=>command('power',{enabled:false});
adapter.setName=name=>command('rename',{name});
adapter.setDiscoverable=enabled=>command('discoverable',{enabled});
adapter.startDiscovery=async()=>{
await command('scan');
discovery=new EventTarget(); seen=new Set();
const handle=discovery;
// Gaia installs its discovery listeners after this promise resolves.
setTimeout(()=>refresh(),0);
return handle;
};
adapter.stopDiscovery=async()=>{await command('stopScan');discovery=null;};
adapter.getPairedDevices=()=>[...devices.values()].filter(d=>d.paired);
adapter.getConnectedDevices=async profile=>{
await refresh();
const uuid=Number(profile).toString(16).padStart(8,'0')+'-0000-1000-8000-00805f9b34fb';
return [...devices.values()].filter(d=>d.connected && d.uuids.includes(uuid));
};
for (const op of ['pair','unpair','connect','disconnect'])
adapter[op]=async value=>{
await command(op,{address:typeof value==='string'?value:value.address});
await refresh();
};
const unsupported=()=>Promise.reject(new DOMException('Bluetooth profile is unavailable','NotSupportedError'));
for (const name of ['sendFile','stopSendingFile','confirmReceivingFile','connectSco','disconnectSco',
'sendMediaMetaData','sendMediaPlayStatus']) adapter[name]=unsupported;
adapter.isScoConnected=()=>Promise.resolve(false);
manager.getAdapters=()=>Promise.resolve(manager.defaultAdapter?[adapter]:[]);
Object.defineProperty(navigator,'mozBluetooth',{value:manager});
let shown = null;
function showPairing() {
if (!initialized || !window.Pairview) return;
const item=currentPairing;
if (!item) {if(shown) window.close();return;}
if (shown===item.id) return;
shown=item.id;
const reply=(accept,code)=>command('reply',{id:item.id,accept,code});
Pairview.init(item.method==='enterpasskey'?'enterpincode':item.method,{
deviceName:item.deviceName,handle:{passkey:item.passkey,
accept:()=>reply(true),reject:()=>reply(false),setPinCode:code=>reply(true,code)},
});
}
addEventListener('DOMContentLoaded',async()=>{
initialized=true;
await refresh(); showPairing();
if(app==='system') {
navigator.mozSettings.addObserver('bluetooth.enabled',event=>{
const enabled=event.settingValue===true;
if((adapter.state==='enabled')!==enabled)
command('power',{enabled}).catch(()=>call('settings.set',{values:{'bluetooth.enabled':adapter.state==='enabled'}}));
});
await call('settings.set',{values:{'bluetooth.enabled':adapter.state==='enabled'}});
}
timer=setInterval(()=>{if(app==='system'||!document.hidden||discovery)refresh();},1500);
},{once:true});
addEventListener('pagehide',()=>{
closed=true;clearInterval(timer);
if(discovery)command('stopScan').catch(()=>{});
},{once:true});
})();

View file

@ -1,6 +1,8 @@
// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
(() => {
if (location.hostname !== 'camera.localhost') return;
const videoMime = typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus') ? 'video/webm;codecs=vp8,opus' : null;
window.VulpesVideoExtension = videoMime ? 'webm' : null;
let cameras = ['back'],
devices = new Map(),
current = null,
@ -8,11 +10,22 @@
let cameraError = null;
let opening = Promise.resolve(), closing = Promise.resolve();
const releases = new WeakMap();
const nativeViews = new WeakMap();
function releaseStream(stream, preview) {
if (releases.has(stream)) return releases.get(stream);
if (current === stream) current = null;
const release = Promise.resolve().then(async () => {
await stream.vulpesFinishRecording?.();
emit(stream, 'previewstatechange', {newState:'stopped'});
// Stop the capture producer before detaching its consumers. Gecko can
// otherwise block in srcObject=null while a GPU-backed frame is in use.
stream.getTracks().forEach(track => track.stop());
for (const video of document.querySelectorAll('video')) {
if (nativeViews.get(video) === stream) {
nativeViews.delete(video);
stream.vulpesPreviewCanvas.remove();
video.style.display = '';
}
if (video.srcObject === stream) {
video.pause(); video.srcObject = null;
if ('vulpesCapture' in video.dataset) video.remove();
@ -96,12 +109,33 @@
Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', {
configurable: true,
get() {
return nativeSource.get.call(this);
return nativeViews.get(this) || nativeSource.get.call(this);
},
set(stream) {
nativeSource.set.call(this, stream);
const old = nativeViews.get(this);
if (old) {
old.vulpesPreviewCanvas.remove();
nativeViews.delete(this);
this.style.display = '';
}
if (stream?.vulpesPreviewCanvas) {
// The HAL already delivers NV21 frames. Render them directly in Gaia's
// viewfinder instead of capturing the WebGL canvas into a second GPU
// video pipeline, which stalls on repeated switches on Sargo.
nativeViews.set(this, stream);
const canvas = stream.vulpesPreviewCanvas;
canvas.className = this.className;
canvas.dataset.vulpesNativePreview = '';
canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none';
this.style.display = 'none';
this.parentNode.append(canvas);
} else nativeSource.set.call(this, stream);
},
});
const playVideo = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
return nativeViews.has(this) ? Promise.resolve() : playVideo.call(this);
};
function emit(target, type, detail = {}) {
const event = new Event(type);
Object.assign(event, detail);
@ -136,15 +170,20 @@
if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back'];
}
// Keep one graphics context per app. Losing/recreating it at every lens switch
// can exhaust the mobile driver while captured video frames are still in use.
let nativeCanvas = null;
async function nativeStream(name) {
let session;
const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session});
const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'});
session = meta.session;
const canvas = document.createElement('canvas');
const canvas = nativeCanvas || (nativeCanvas = document.createElement('canvas'));
const sideways = meta.orientation % 180 !== 0;
canvas.width = sideways ? meta.height : meta.width;
canvas.height = sideways ? meta.width : meta.height;
const width = sideways ? meta.height : meta.width;
const height = sideways ? meta.width : meta.height;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
// Convert NV21 in a shader instead of blocking Gaia's UI with one JS loop
// per pixel. The two textures contain Y and interleaved V/U respectively.
const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true});
@ -152,18 +191,21 @@
await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
}
const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
let stopped = false, timer, stream, sequence = -1, closing, program, buffer;
const timing = {frames:0, startedAt:performance.now(), conversionMs:0, transferMs:0, sensorFrames:0};
let stopped = false, timer, stream, wakeLock, sequence = -1, closing, program, buffer;
const textures = [], shaders = [];
const stop = () => {
if (closing) return closing;
stopped = true; clearTimeout(timer);
wakeLock?.unlock(); wakeLock = null;
closing = call({action:'stop'}).finally(() => {
if (stream) stream.vulpesClosed = true;
textures.forEach(texture => gl.deleteTexture(texture));
shaders.forEach(value => gl.deleteShader(value));
if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
// The next lens reuses this context after its old stream has stopped.
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
});
return closing;
};
@ -210,17 +252,25 @@
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(program,name),index);
});
gl.viewport(0,0,canvas.width,canvas.height);
const pixels = meta.width * meta.height;
gl.activeTexture(gl.TEXTURE0);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,meta.width,meta.height,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,null);
gl.activeTexture(gl.TEXTURE1);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,meta.width/2,meta.height/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,null);
const draw = async () => {
const requested = performance.now();
const frame = await call({action:'frame'});
timing.transferMs += performance.now()-requested;
if (stopped || !frame.data || frame.sequence === sequence) return false;
const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
sequence = frame.sequence;
sequence = frame.sequence; timing.sensorFrames = sequence;
const start=performance.now();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w,h,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,pixels));
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w/2,h/2,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(pixels));
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
timing.frames++;timing.conversionMs+=performance.now()-start;
return true;
@ -230,7 +280,11 @@
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
await new Promise(resolve => setTimeout(resolve, 50));
}
stream = canvas.captureStream(24);
stream = new MediaStream();
stream.vulpesPreviewCanvas = canvas;
stream.vulpesClosed = false;
// A canvas has no video element's implicit screen wake lock.
wakeLock = navigator.requestWakeLock?.('screen');
stream.vulpesTiming = timing;
stream.vulpesNative = true;
stream.vulpesCameraMetadata = meta;
@ -245,15 +299,28 @@
const started = performance.now();
try { await draw(); }
catch (error) {
if (!stopped) { recordError(error); stream.getTracks().forEach(track => track.stop()); }
if (!stopped) { recordError(error); await stop().catch(recordError); }
return;
}
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/24-(performance.now()-started)));
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/30-(performance.now()-started)));
};
timer = setTimeout(next, 1000/24);
timer = setTimeout(next, 1000/30);
return stream;
} catch (error) { await stop().catch(() => {}); throw error; }
}
async function captureVideo(constraints, ticket, platform) {
// track.stop() returns before some Android camera drivers finish closing.
// Retry the same sensor, never replace a refused/missing camera silently.
for (let attempt = 0; ; attempt++) {
if (document.hidden || ticket !== generation)
throw new DOMException('Camera request superseded or hidden', 'AbortError');
try { return await navigator.mediaDevices.getUserMedia(constraints); }
catch (error) {
if (platform !== 'android' || error.name !== 'NotReadableError' || attempt >= 2) throw error;
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
}
}
}
function getCamera(name, config = {}) {
const ticket = ++generation;
const request = opening.then(() => openCamera(name, config, ticket));
@ -275,7 +342,7 @@
const platform = await VulpesCompat.call('platform.capabilities', {});
if (platform.platform === 'tundra' && !platform.camera)
throw new DOMException('Native camera adapter unavailable', 'NotSupportedError');
stream = platform.platform === 'tundra' ? await nativeStream(name) : await navigator.mediaDevices.getUserMedia({
stream = platform.platform === 'tundra' ? await nativeStream(name) : await captureVideo({
audio: false,
video: {
...(selected
@ -284,7 +351,7 @@
width: { ideal: 1920 },
height: { ideal: 1080 },
},
});
}, ticket, platform.platform);
} catch (error) {
recordError(error);
throw error;
@ -304,44 +371,55 @@
throw new DOMException('Camera hidden', 'AbortError');
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
const size = { width: settings.width, height: settings.height };
preview = document.createElement('video');
preview.muted = true;
preview.playsInline = true;
preview.srcObject = stream;
preview.dataset.vulpesCapture = '';
preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
document.body.append(preview);
let previewTimeout;
await Promise.race([
preview.play(),
new Promise((_, reject) => {
previewTimeout = setTimeout(
() => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
10000,
);
}),
]).finally(() => clearTimeout(previewTimeout));
const native = stream.vulpesCameraMetadata;
let size;
if (native) {
// Preview dimensions and still photos come directly from the HAL.
// The native canvas needs no hidden capture video.
const sideways = native.orientation % 180 !== 0;
size = {width:sideways ? native.height : native.width,
height:sideways ? native.width : native.height};
} else {
preview = document.createElement('video');
preview.muted = true;
preview.playsInline = true;
preview.srcObject = stream;
preview.dataset.vulpesCapture = '';
preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
document.body.append(preview);
let previewTimeout;
await Promise.race([
preview.play(),
new Promise((_, reject) => {
previewTimeout = setTimeout(
() => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
10000,
);
}),
]).finally(() => clearTimeout(previewTimeout));
const settings = stream.getVideoTracks()[0].getSettings();
size = {width:preview.videoWidth || settings.width,
height:preview.videoHeight || settings.height};
}
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
const native = stream.vulpesCameraMetadata;
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;
let focusArea = null, zoom = 1, 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: 'picture',
mode,
previewSize: size,
pictureSize,
recorderProfile: null,
recorderProfile: videoMime ? 'webm' : null,
});
Object.assign(stream, {
capabilities: {
pictureSizes,
previewSizes: [size],
thumbnailSizes: [],
recorderProfiles: {},
recorderProfiles: videoMime ? {webm:profile} : {},
flashModes: ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'],
@ -359,8 +437,9 @@
zoom: 1,
async setConfiguration(value) {
if (value.pictureSize) stream.setPictureSize(value.pictureSize);
if (value.mode && value.mode !== 'picture')
if (value.mode && !['picture',...(videoMime ? ['video'] : [])].includes(value.mode))
throw new DOMException('Video unavailable', 'NotSupportedError');
if(value.mode)mode=value.mode;
queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' }));
return configuration();
},
@ -415,10 +494,66 @@
release() {
return releaseStream(stream, preview);
},
async startRecording() {
throw new DOMException('Video unavailable', 'NotSupportedError');
async startRecording(options, storage, filepath) {
if(!videoMime)throw new DOMException('Video unavailable','NotSupportedError');
if(recording || startingRecording)throw new DOMException('Recording in progress','InvalidStateError');
startingRecording=true;
let audio, captured;
try {
audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
if(current!==stream || document.hidden)throw new DOMException('Camera closed','AbortError');
// Only recording captures the native canvas. The preview keeps its
// direct WebGL surface and the capture track is stopped before a switch.
captured=native ? stream.vulpesPreviewCanvas.captureStream(30) : new MediaStream(stream.getVideoTracks().map(t=>t.clone()));
for(const track of audio.getAudioTracks())captured.addTrack(track);
const recorder=new MediaRecorder(captured,{mimeType:videoMime,videoBitsPerSecond:1500000,audioBitsPerSecond:64000});
const chunks=[];let bytes=0,failed=false,finish;
const done=new Promise(resolve=>{finish=resolve;});
const cap=Math.min(options.maxFileSizeBytes || Infinity,32*1024*1024);
recorder.ondataavailable=event=>{
if(event.data.size) {chunks.push(event.data);bytes+=event.data.size;}
if(bytes>=cap && recorder.state==='recording') {
emit(stream,'recorderstatechange',{newState:'FileSizeLimitReached'});
recorder.stop();
}
};
recorder.onerror=()=>{failed=true;emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});};
recorder.onstop=async()=>{
try {
if(failed || !bytes)throw Error('Empty or failed recording');
const blob=new Blob(chunks,{type:'video/webm'});
await storage.addNamed(blob,filepath);
const canvas=document.createElement('canvas');
const source=native ? stream.vulpesPreviewCanvas : preview;
canvas.width=native ? source.width : preview.videoWidth;
canvas.height=native ? source.height : preview.videoHeight;
canvas.getContext('2d').drawImage(source,0,0,canvas.width,canvas.height);
const poster=await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.9));
if(!poster)throw Error('Video poster failed');
emit(stream,'poster',{data:poster});
} catch(error) {
recordError(error);emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});
} finally {
captured.getTracks().forEach(t=>t.stop());
chunks.length=0;recording=null;finish();
emit(stream,'recorderstatechange',{newState:'Stopped'});
}
};
recording={recorder,done};
recorder.start(500);
emit(stream,'recorderstatechange',{newState:'Started'});
} catch(error) {
captured?.getTracks().forEach(t=>t.stop());audio?.getTracks().forEach(t=>t.stop());
recording=null;throw error;
} finally {startingRecording=false;}
},
stopRecording() {
if(recording?.recorder.state==='recording')recording.recorder.stop();
},
async vulpesFinishRecording() {
if(!recording)return;
const done=recording.done;stream.stopRecording();await done;
},
stopRecording() {},
});
if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom,
@ -433,7 +568,7 @@
});
dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', {
detail: { cameras: [...cameras], flash: false, video: false },
detail: { cameras: [...cameras], flash: false, video: !!videoMime },
}),
);
return { camera: stream, configuration: configuration() };
@ -479,7 +614,7 @@
'Only one camera available',
'toggle-camera-rear',
],
['.mode-switch', true, 'Vidéo pas encore disponible', 'Video is not available yet', null],
['.mode-switch', !videoMime, 'Vidéo pas encore disponible', 'Video is not available yet', null],
];
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
for (const [selector, disabled, french, english, icon] of controls) {
@ -489,7 +624,7 @@
e.setAttribute('aria-disabled', String(disabled));
if (!disabled) {
e.removeAttribute('title');
e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
if(selector==='.js-camera')e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
}
if (disabled) {
e.removeAttribute('data-l10n-id');

View file

@ -67,7 +67,25 @@
const unsupported = () => Promise.reject(new DOMException('Not available on this adapter', 'NotSupportedError'));
telephony.conferenceGroup = Object.assign(new EventTarget(), {calls:[], state:'', add:unsupported, remove:unsupported, hangUp:unsupported});
telephony.ready = Promise.resolve();
telephony.dial = number => call('platform.dial',{number});
class TelephonyCall extends EventTarget {}
Object.defineProperty(window,'TelephonyCall',{value:TelephonyCall});
function makeCall(item) {
return Object.assign(new TelephonyCall(),{path:item.path,id:{number:item.number},serviceId:0,
state:({active:'connected',waiting:'incoming'})[item.state] || item.state,
emergency:false,group:null,switchable:false,mergeable:false,
answer:()=>call('platform.answer',{path:item.path}),hangUp:()=>call('platform.hangup',{path:item.path}),
hold:unsupported,resume:unsupported});
}
telephony.dial = async number => {
const result = await call('platform.dial',{number});
let current = telephony.calls.find(c=>c.path===result.path);
if (!current) {
current = makeCall({path:result.path,number,state:'dialing'});
telephony.calls.push(current);
emit(telephony,'callschanged');
}
return current;
};
telephony.dialEmergency = unsupported;
telephony.sendTones = unsupported;
telephony.startTone = unsupported;
@ -94,14 +112,17 @@
const state = ({active:'connected',waiting:'incoming'})[item.state] || item.state;
if (!c) {
changed = true;
c = Object.assign(new EventTarget(),{path:item.path,id:{number:item.number}, serviceId:0,
state, emergency:false, group:null, switchable:false, mergeable:false,
answer:()=>call('platform.answer',{path:item.path}), hangUp:()=>call('platform.hangup',{path:item.path}),
hold:unsupported,resume:unsupported});
c = makeCall(item);
} else if (c.state !== state) { c.state=state;changes.push(c); }
return c;
});
for (const c of old) if (!telephony.calls.includes(c)) {c.state='disconnected';changes.push(c);changed=true;}
for (const c of old) if (!telephony.calls.includes(c)) {
c.state='disconnected';
// The broker currently supplies no modem disconnect cause. Avoid
// inventing an error when a call simply disappears after hang-up.
c.disconnectedReason=null;
changes.push(c);changed=true;
}
for (const c of changes) {emit(c,'statechange',{call:c});emit(c,c.state,{call:c});}
if (changed) {
if (location.hostname === 'system.localhost' && live.some(c => ['incoming','waiting'].includes(c.state))) window.Service?.request('turnScreenOn');
@ -131,13 +152,13 @@
const settingsCodec = window.VulpesSettingsCodec;
async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
if (settingsCodec && (method === 'settings.set' ||
if (settingsCodec && (method === 'settings.set' || method === 'messages.request' ||
(window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.request'].includes(method))))
params = await settingsCodec.encode(params);
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
return settingsCodec &&
['settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
['messages.request', 'settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
? settingsCodec.decode(reply.result)
: reply.result;
});
@ -217,11 +238,15 @@
}
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 === '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') {
dispatchEvent(new CustomEvent('secure-killapps'));
window.Service?.request('lock').catch(console.error);
settings.createLock().get('lockscreen.enabled').then(values => {
if (values['lockscreen.enabled'] === false) return;
dispatchEvent(new CustomEvent('secure-killapps'));
window.Service?.request('lock').catch(console.error);
}).catch(console.error);
}
if (type === 'screen-on') window.ScreenManager?.turnScreenOn(true);
if (type === 'home' || type === 'holdhome') {
@ -230,6 +255,10 @@
}
if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
});
if (location.hostname==='system.localhost') addEventListener('mozContentEvent', event=>{
if (event.detail?.type==='activity-choice')
call('activities.select',{id:event.detail.id,value:event.detail.value}).catch(console.error);
});
function application(record) {
// DOMApplication never exposed the registry's internal id. Gaia uses `id`
// to distinguish a bookmark from an installed application.
@ -520,7 +549,7 @@
frames.add(frame);
// Set delegation before navigation; a MutationObserver runs too late on Android.
if (location.hostname === 'system.localhost')
frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port);
frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port + '; microphone http://camera.localhost:' + location.port);
frame.addEventListener('load', () => {
if (!frame.hasAttribute('mozbrowser')) return;
if (webViews.has(frame)) return;

View file

@ -1,6 +1,10 @@
// Keep Gaia's keyboard and layouts; replace its retired Gecko input-method API.
(() => {
if (!window.VulpesNativeInput) return;
function start() {
// Android emits focus only when enabled. Install the keyboard API before Gaia's
// startup scripts, without waiting for the asynchronous preference handshake.
if ((!window.VulpesNativeInput && window.VulpesPlatform !== 'android') || window.__vulpesInputInstalled) return;
window.__vulpesInputInstalled=true;
const app=location.hostname.split('.')[0], {call}=VulpesCompat;
if (app==='keyboard') {
let viewport, pendingState;
@ -16,13 +20,14 @@
dispatchEvent(new Event('resize'));
if(pendingState!==undefined) {setState(pendingState);pendingState=undefined;}
});
addEventListener('DOMContentLoaded', () => {
const ready = () => {
ViewManager.prototype.screenInPortraitMode = function() {
return viewport ? viewport.width<=viewport.height :
this.cachedWindowWidth<=this.cachedWindowHeight;
};
parent.postMessage({type:'vulpes-keyboard-ready'},'http://system.localhost:8765');
}, {once:true});
};
if(document.readyState==='loading')addEventListener('DOMContentLoaded',ready,{once:true});else ready();
const ime=Object.assign(new EventTarget(),{inputcontext:null,mgmt:{
supportsSwitching:()=>false,
hide:()=>call('input.request',{operation:'hide'}),
@ -93,4 +98,7 @@
for(const type of ['home','lockscreen-appopened','appwillclose'])
addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error));
}
}
addEventListener('vulpes-input-configured',start);
start();
})();

View file

@ -0,0 +1,366 @@
// 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;
const {call} = VulpesCompat;
const read = action => call('platform.mobileSettings', {action});
const unavailable = () => Promise.reject(new DOMException('Not supported by this adapter', 'NotSupportedError'));
const icc = Object.assign(new EventTarget(), {cardState:'unknown', iccInfo:null});
const conn = Object.assign(new EventTarget(), {iccId:null, radioState:'unknown', supportedNetworkTypes:[], voice:{type:null}, data:{type:null}});
const manager = Object.assign(new EventTarget(), {iccIds:[], getIccById:id => id && id === conn.iccId ? icc : null});
let fixedDialing, pinEnabled;
icc.getCardLock = async name => {
await VulpesMobileSettings.ready;
const enabled = name === 'fdn' ? fixedDialing : name === 'pin' ? pinEnabled : undefined;
if (typeof enabled !== 'boolean') return unavailable();
return {enabled};
};
// Fixed-dialling changes, PIN operations and SIM contacts are not ported.
icc.getServiceState = () => Promise.resolve(false);
for (const method of ['unlockCardLock','setCardLock','getCardLockRetryCount','readContacts','updateContact']) icc[method] = unavailable;
// Supplementary services can fail independently of reading the SIM itself.
// Do not pretend a modem query is a successful preference change.
conn.getCallingLineIdRestriction = unavailable;
conn.setCallingLineIdRestriction = unavailable;
conn.getCallWaitingOption = () => read('calls').then(value => {
if (!['enabled','disabled'].includes(value.VoiceCallWaiting)) throw Error('RADIO_NOT_READY');
return value.VoiceCallWaiting === 'enabled';
});
conn.setCallWaitingOption = unavailable;
conn.getVoicePrivacyMode = unavailable; conn.setVoicePrivacyMode = unavailable;
Object.defineProperty(navigator,'mozMobileConnections',{value:[conn]});
Object.defineProperty(navigator,'mozIccManager',{value:manager});
Object.defineProperty(navigator,'mozMobileMessage',{value:{getSmscAddress:() => read('smsc').then(v=>({address:v.address || ''}))}});
async function refresh() {
const state = await read('sim');
const previous = conn.iccId;
conn.radioState = state.radioEnabled ? 'enabled' : 'disabled';
conn.dispatchEvent(new Event('radiostatechange'));
conn.iccId = state.present ? state.id : null;
fixedDialing = state.fixedDialing; pinEnabled = state.pinEnabled;
manager.iccIds = conn.iccId ? [conn.iccId] : [];
icc.cardState = !state.present ? 'absent' : state.pin === 'none' ? 'ready' : state.pin === 'pin' ? 'pinRequired' : state.pin === 'puk' ? 'pukRequired' : 'unknown';
icc.iccInfo = {iccid:conn.iccId, spn:state.operator, msisdn:state.numbers?.[0] || '', iccType:'sim', isDisplaySpnRequired:true};
if (previous !== conn.iccId) manager.dispatchEvent(Object.assign(new Event(conn.iccId ? 'iccdetected' : 'iccundetected'),{iccId:conn.iccId || previous}));
icc.dispatchEvent(new Event('cardstatechange')); icc.dispatchEvent(new Event('iccinfochange'));
}
let messageState, changing = false;
async function messaging(panel) {
const control=panel.querySelector('[name="ril.sms.requestStatusReport.enabled"]');
if (!control || control.dataset.vulpesBound) return;
control.dataset.vulpesBound='true'; control.disabled=true;
control.addEventListener('change', async event => {
event.stopImmediatePropagation();
const desired=control.checked;
if (changing || typeof messageState !== 'boolean') return;
changing=true;control.disabled=true;
try {
const result=await call('platform.mobileSettings',{action:'deliveryReports',enabled:desired});
messageState=result.deliveryReports;
await navigator.mozSettings.createLock().set({'ril.sms.requestStatusReport.enabled':messageState});
} catch(error) {window.VulpesGaiaUI?.notice('SMS: '+error.message);}
finally {control.checked=messageState;changing=false;control.disabled=false;}
},true);
try {
const state=await read('messages');
if (typeof state.deliveryReports !== 'boolean') return;
messageState=state.deliveryReports;
control.checked=messageState;
await navigator.mozSettings.createLock().set({'ril.sms.requestStatusReport.enabled':messageState});
control.disabled=false;control.closest('li').removeAttribute('aria-disabled');
control.dataset.vulpesSupported='true';
} catch(error) {console.warn('Message settings:',error.message);}
}
function carrierPanel() {
let panel, state, pending=false;
const controls = () => [panel.querySelector('#menuItem-enableDataCall gaia-switch'), panel.querySelector('#menuItem-enableDataRoaming gaia-switch')];
async function sync() {
if(typeof state?.enabled==='boolean' && typeof state?.roaming==='boolean')
await navigator.mozSettings.createLock().set({'ril.data.enabled':state.enabled,'ril.data.roaming_enabled':state.roaming});
}
function render() {
const [data,roaming]=controls();
for(const [control,key] of [[data,'enabled'],[roaming,'roaming']]) {
control.disabled=pending || typeof state?.[key]!=='boolean';
control.checked=state?.[key]===true;
control.closest('li').setAttribute('aria-disabled',String(control.disabled));
}
panel.querySelector('.carrier-info').hidden=false;
panel.querySelector('.carrier-advancedSettings').hidden=false;
panel.querySelector('#dataNetwork-desc').textContent=[state?.operator,(state?.technology||'').toUpperCase(),state?.status].filter(Boolean).join(' · ');
}
return {
onInit(element) {
panel=element;
for(const [control,action] of [[controls()[0],'dataEnabled'],[controls()[1],'roaming']]) {
control.addEventListener('change',async event=>{
event.stopImmediatePropagation();if(pending)return;
const enabled=control.checked;pending=true;render();
try {state=await call('platform.mobileSettings',{action,enabled});await sync();}
catch(error) {window.VulpesGaiaUI?.notice(error.message);}
finally {pending=false;render();}
},true);
}
},
async onBeforeShow() {
pending=true;render();
try {state=await read('network');await sync();}
catch(error) {window.VulpesGaiaUI?.notice(error.message);}
finally {pending=false;render();}
}
};
}
const fr = () => document.documentElement.lang.toLowerCase().startsWith('fr');
const text = (f,e) => fr()?f:e;
const navigate = (id,options={}) => require(['modules/settings_service'],service=>service.navigate(id,options));
function explain(panel,key,message) {
let row=panel.querySelector('[data-vulpes-note="'+key+'"]');
if(!row) {row=document.createElement('p');row.className='explanation';row.dataset.vulpesNote=key;(panel.querySelector(':scope > div')||panel).prepend(row);}
row.textContent=message;
}
function failure(error) {
const messages={
APN_IN_USE:text('Désactivez les données mobiles avant de modifier ce profil actif.','Turn off mobile data before editing this active profile.'),
APN_WRITE_FAILED:text('Enregistrement impossible. Le profil précédent a été restauré.','Could not save. The previous profile was restored.'),
APN_ROLLBACK_FAILED:text('Le profil n’a pas pu être restauré. Vérifiez ses paramètres avant de réactiver les données.','The profile could not be restored. Check its settings before re-enabling data.'),
RADIO_READBACK_FAILED:text('Le réseau n’a pas confirmé le réglage. Son état va être relu.','The network did not confirm the setting. Its state will be read again.'),
INVALID_REQUEST:text('Vérifiez les valeurs saisies.','Check the values you entered.')
};
window.VulpesGaiaUI?.notice(messages[error.message]||text('Le service réseau n’a pas pu appliquer ce réglage.','The network service could not apply this setting.'));
}
function callPanel() {
let panel,waiting,caller,state,pending=false;
const modes={default:'CLIR_DEFAULT',enabled:'CLIR_INVOCATION',disabled:'CLIR_SUPPRESSION'};
function render() {
waiting.disabled=pending || !['enabled','disabled'].includes(state?.VoiceCallWaiting);
waiting.checked=state?.VoiceCallWaiting==='enabled';
waiting.closest('li').dataset.state=waiting.disabled?'unknown':waiting.checked?'on':'off';
caller.disabled=pending || !Object.hasOwn(modes,state?.HideCallerId);
caller.value=modes[state?.HideCallerId]||'';
for(const c of [waiting,caller])c.closest('li').setAttribute('aria-disabled',String(c.disabled));
explain(panel,'call-state',pending?text('Interrogation du réseau… Cela peut prendre plusieurs secondes.','Querying the network… This may take several seconds.'):text('Le double appel et la présentation du numéro utilisent les réglages de votre opérateur.','Call waiting and caller ID use your carrier’s settings.'));
}
async function update(params) {
if(pending)return;
pending=true;render();
try {state=await call('platform.mobileSettings',params);}
catch(error) {state=null;failure(error);try {state=await read('calls');}catch(_) {}}
finally {pending=false;render();}
}
return {onInit(element){
panel=element;waiting=panel.querySelector('#menuItem-callWaiting gaia-switch');caller=panel.querySelector('#ril-callerId');caller.removeAttribute('data-setting');
for(const id of ['menuItem-callWaiting','menuItem-callerId','menuItem-callForwarding','menuItem-callFdn','menuItem-callBarring'])panel.querySelector('#'+id).hidden=false;
waiting.addEventListener('change',()=>update({action:'setCallWaiting',enabled:waiting.checked}));
caller.addEventListener('change',()=>update({action:'setCallerId',mode:Object.keys(modes).find(key=>modes[key]===caller.value)}));
for(const [id,target] of [['menuItem-callForwarding','call-forwarding'],['menuItem-callFdn','call-fdnSettings'],['menuItem-callBarring','call-cbSettings']]) {
const a=panel.querySelector('#'+id+' a');a.removeAttribute('data-href');a.href='#'+target;
a.addEventListener('click',event=>{event.preventDefault();event.stopImmediatePropagation();navigate(target);},true);
}
const voicemail=panel.querySelector('.menuItem-voicemail');voicemail.setAttribute('aria-disabled','true');
voicemail.addEventListener('click',event=>{event.preventDefault();event.stopImmediatePropagation();},true);
document.l10n.setAttributes(panel.querySelector('#voiceMail-desc'),'unavailable');
explain(panel,'call-limits',text('Le numéro de messagerie vocale, les numéros autorisés et le blocage des appels ne sont pas encore raccordés.','Voicemail number, fixed dialling and call barring are not connected yet.'));
render();
},onBeforeShow(){void update({action:'calls'});}};
}
function forwardingPanel() {
let panel,state,pending=false;
const rows={
unconditional:['cfu','call-cf-unconditional-settings','.callForwardingUnconditional'],
busy:['cfmb','call-cf-mobile-busy-settings','.callForwardingMobileBusy'],
noReply:['cfnrep','call-cf-no-reply-settings','.callForwardingNoReply'],
notReachable:['cfnrea','call-cf-not-reachable-settings','.callForwardingNotReachable']
};
function render() {
for(const [reason,[id,,selector]] of Object.entries(rows)) {
const known=typeof state?.[reason]==='string';
panel.querySelector(selector).setAttribute('aria-disabled',String(pending||!known));
const node=panel.querySelector('#'+id+'-desc');
document.l10n.setAttributes(node,pending?'callSettingsQuery':!known?'callSettingsQueryError':state[reason]?'callForwardingForwardingVoiceToNumber':'callForwardingNotForwarding',state?.[reason]?{number:state[reason]}:{});
}
}
async function load() {
if(pending)return;
pending=true;render();
try {state=await read('forwarding');}catch(error){state=null;failure(error);}
finally {pending=false;render();}
}
return {onInit(element){
panel=element;
for(const [reason,[,dialog,selector]] of Object.entries(rows))panel.querySelector(selector).addEventListener('click',async event=>{
event.preventDefault();event.stopImmediatePropagation();
if(pending || typeof state?.[reason]!=='string')return;
const service=await new Promise(resolve=>require(['modules/dialog_service'],resolve));
const result=await service.show(dialog,{number:state[reason],enabled:!!state[reason]});
if(result.type!=='submit')return;
const enabled=!!result.value.enabled;
const number=enabled?String(result.value.number||'').replace(/[\s()-]/g,''):'';
pending=true;render();
try {state=await call('platform.mobileSettings',{action:'setForwarding',reason,enabled,number});}
catch(error){failure(error);try{state=await read('forwarding');}catch(_){state=null;}}
finally {pending=false;render();}
});
},onBeforeShow(){void load();}};
}
function operatorPanel() {
let panel,select,pending=false,state;
return {
onInit(element) {
panel=element;select=panel.querySelector('.preferred-network-type select');
// Operator scanning/registration has a different service and remains
// unavailable; it must not be confused with the radio mode selector.
panel.querySelector('.auto-select gaia-switch').disabled=true;
panel.querySelector('.search-btn').disabled=true;
select.addEventListener('change',async()=>{
if(pending || !state)return;
const mode=select.value;pending=true;select.disabled=true;
try {state=await call('platform.mobileSettings',{action:'setRadioMode',mode});}
catch(error){failure(error);}
finally {select.value=state.mode;pending=false;select.disabled=false;}
});
},
async onBeforeShow() {
select.disabled=true;
explain(panel,'operator',text('Le type de réseau est réglable. La recherche et la sélection manuelle d’opérateur ne sont pas encore raccordées.','Network type can be changed. Operator scanning and manual selection are not connected yet.'));
try {
const [radio,network]=await Promise.all([read('radioMode'),read('network')]);state=radio;
select.replaceChildren();
const names={any:text('Automatique','Automatic'),gsm:'2G (GSM)',umts:'3G (UMTS)',lte:'4G (LTE)'};
for(const mode of radio.available)select.add(new Option(names[mode]||mode,mode));
select.value=radio.mode;select.disabled=!radio.available.includes(radio.mode);
panel.querySelector('.auto-select gaia-switch').checked=network.mode==='auto';
} catch(error){failure(error);}
}
};
}
function apnSettingsPanel() {
return {onInit(panel){
for(const anchor of panel.querySelectorAll('[data-apn-type]')) {
const supported=['default','mms'].includes(anchor.dataset.apnType);
if(!supported)anchor.setAttribute('aria-disabled','true');
anchor.addEventListener('click',event=>{
event.preventDefault();event.stopImmediatePropagation();
if(supported)navigate('apn-list',{type:anchor.dataset.apnType});
});
}
panel.querySelector('.reset-apn').disabled=true;
},onBeforeShow(panel){
explain(panel,'apn',text('Modifiez les profils Internet et MMS existants. Les profils IMS restent gérés par le modem. L’ajout, la suppression et la réinitialisation ne sont pas encore disponibles.','Edit existing Internet and MMS profiles. IMS profiles remain managed by the modem. Adding, deleting and resetting profiles are not available yet.'));
}};
}
function apnListPanel() {
let panel,type='default';
return {onInit(element){panel=element;panel.querySelector('.add-apn').disabled=true;panel.querySelector('gaia-header').dataset.href='#apn-settings';},
async onBeforeShow(element,options={}) {
type=options.type==='mms'?'mms':'default';
document.l10n.setAttributes(panel.querySelector('h1'),type==='mms'?'messageSettings-header':'dataSettings-header');
const list=panel.querySelector('.apn-list');list.replaceChildren();
try {
const profiles=(await read('apns')).filter(p=>p.type===type);
const template=await new Promise(resolve=>require(['panels/apn_list/apn_template_factory'],factory=>resolve(factory(type,item=>navigate('apn-editor',{profileId:item.id,type}),()=>{}))));
for(const profile of profiles) {
const li=template({id:profile.id,apn:{apn:profile.fields.apn},active:profile.active});
// This mark reflects an active data context; changing the profile
// does not activate/deactivate a bearer implicitly.
const radio=li.querySelector('gaia-radio');radio.setAttribute('aria-readonly','true');
list.append(li);
}
explain(panel,'list',profiles.length?text('Touchez le nom du profil pour le modifier.','Tap a profile name to edit it.'):text('Aucun profil fourni par le modem.','The modem returned no profile.'));
}catch(error){failure(error);}
}
};
}
function apnEditorPanel() {
let panel,profile,pending=false,dirty=new Set();
const selectors={apn:'.apn',user:'.user',password:'.password',protocol:'.protocol',authtype:'.authtype',mmsc:'.mmsc',mmsproxy:'.mmsproxy'};
const protocols={ip:'IP',ipv6:'IPV6',dual:'IPV4V6'};
const auth={none:'none',pap:'pap',chap:'chap',any:'papOrChap'};
function editable() {
for(const [key,selector] of Object.entries(selectors))panel.querySelector(selector).disabled=pending || !profile || (['mmsc','mmsproxy'].includes(key)&&profile.type!=='mms');
panel.querySelector('.ok').disabled=pending || !profile;
}
function render() {
editable();
for(const [key,selector] of Object.entries(selectors)) {
const input=panel.querySelector(selector);
input.disabled=pending || !profile || (['mmsc','mmsproxy'].includes(key)&&profile.type!=='mms');
const value=profile?.fields[key]||'';
input.value=key==='password'?'':key==='protocol'?protocols[value]||'notDefined':key==='authtype'?auth[value]||'notDefined':value;
if(key==='password')input.placeholder=profile?.hasPassword?text('Inchangé','Unchanged'):'';
}
panel.querySelector('.ok').disabled=pending || !profile;
}
return {onInit(element){
panel=element;
for(const input of panel.querySelectorAll('input,select,button'))input.disabled=true;
for(const [key,selector] of Object.entries(selectors))for(const event of ['input','change'])panel.querySelector(selector).addEventListener(event,()=>dirty.add(key));
panel.querySelector('.ok').addEventListener('click',async()=>{
if(pending||!profile)return;
const fields={};
for(const key of dirty) {
const value=panel.querySelector(selectors[key]).value;
fields[key]=key==='protocol'?Object.keys(protocols).find(k=>protocols[k]===value):key==='authtype'?Object.keys(auth).find(k=>auth[k]===value):value;
if(fields[key]===undefined){failure({message:'INVALID_REQUEST'});return;}
}
if(!dirty.size){navigate('apn-list',{type:profile.type});return;}
pending=true;editable();
try {
profile=await call('platform.mobileSettings',{action:'editApn',id:profile.id,fields});
dirty.clear();navigate('apn-list',{type:profile.type});
}catch(error){failure(error);}
finally {pending=false;editable();}
});
panel.querySelector('gaia-header').addEventListener('action',event=>{event.stopImmediatePropagation();if(!pending)navigate('apn-list',{type:profile?.type||'default'});},true);
},async onBeforeShow(element,options={}) {
profile=null;dirty.clear();render();
explain(panel,'fields',text('Les champs grisés ne sont pas gérés par ce modem. Le champ mot de passe vide conserve le secret existant tant que vous ne le modifiez pas.','Greyed-out fields are not handled by this modem. An untouched password field keeps the existing secret.'));
try {profile=(await read('apns')).find(p=>p.id===options.profileId);if(!profile)throw Error('APN_NOT_FOUND');render();}
catch(error){failure(error);}
},onHide(){panel.querySelector('.password').value='';dirty.clear();}};
}
let clockState, clockUpdating=false;
Object.defineProperty(navigator,'mozTime',{value:{set:async date=>{
try {
await call('platform.deviceSettings',{action:'setTime',milliseconds:+date});
dispatchEvent(new Event('moztimechange'));
} catch(error) {window.VulpesGaiaUI?.notice(error.message);}
}}});
async function clock() {
clockState=await call('platform.deviceSettings',{action:'clock'});
clockUpdating=true;
try {await navigator.mozSettings.createLock().set({
'time.timezone':clockState.timezone, 'time.timezone.user-selected':clockState.timezone,
'time.clock.automatic-update.available':clockState.automaticAvailable,
'time.clock.automatic-update.enabled':clockState.automatic,
'time.timezone.automatic-update.available':false
});} finally {clockUpdating=false;}
return clockState;
}
navigator.mozSettings.addObserver('time.clock.automatic-update.enabled',async event=>{
if(clockUpdating || !clockState || event.settingValue===clockState.automatic)return;
try {
clockState=await call('platform.deviceSettings',{action:'setAutomatic',enabled:event.settingValue});
await call('settings.clockChanged',{});
dispatchEvent(new Event('moztimechange'));
} catch(error) {
window.VulpesGaiaUI?.notice(error.message);
await clock();
}
});
navigator.mozSettings.addObserver('time.timezone',async event=>{
if(clockUpdating || !clockState || event.settingValue===clockState.timezone)return;
try {
clockState=await call('platform.deviceSettings',{action:'setTimezone',timezone:event.settingValue});
await call('settings.clockChanged',{});
dispatchEvent(new Event('moztimechange'));
} catch(error) {
window.VulpesGaiaUI?.notice(error.message);
await navigator.mozSettings.createLock().set({'time.timezone':clockState.timezone});
}
});
clock().catch(error=>console.warn('Clock settings:',error.message));
window.VulpesMobileSettings = {ready:refresh(), refresh, messaging, clock, panels:{call:callPanel,'call-forwarding':forwardingPanel,carrier:carrierPanel,'operator-settings':operatorPanel,'apn-settings':apnSettingsPanel,'apn-list':apnListPanel,'apn-editor':apnEditorPanel}};
VulpesMobileSettings.ready.catch(error=>console.warn('SIM settings:',error.message));
})();

View file

@ -3,6 +3,22 @@
const app = location.hostname.split('.')[0];
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
const label = (a, b) => fr() ? a : b;
const labels = new Map();
function localize(node, french, english, attribute) {
labels.set(node, {french, english, attribute});
translate();
}
function translate() {
for (const [node, entry] of labels) {
const value = label(entry.french, entry.english);
if (entry.attribute) {
if (node.getAttribute(entry.attribute) !== value) node.setAttribute(entry.attribute, value);
} else if (node.textContent !== value) node.textContent = value;
}
}
addEventListener('localized', translate);
new MutationObserver(translate).observe(document.documentElement, {attributes:true, attributeFilter:['lang']});
function notice(message) {
document.getElementById('vulpes-gaia-notice')?.remove();
const link = document.createElement('link');
@ -28,6 +44,14 @@
window.VulpesGaiaUI = {notice};
async function ready() {
if (app === 'system') {
// Android reserves the edge swipe for its own shade. A tap on Gaia's
// status bar provides access without competing with that system gesture.
if (window.VulpesPlatform === 'android') {
document.getElementById('statusbar')?.addEventListener('click', () => {
if (!window.Service?.query('locked')) window.UtilityTray?.show(true);
});
}
// OperatorIcon also owns the date. It must exist even without a modem.
const startDate = async () => {
if (!window.BaseIcon || !window.Service || !window.LazyLoader) return false;
@ -50,8 +74,8 @@
const li = document.createElement('li'), button = document.createElement('button'), error = document.createElement('p');
li.id = 'import-sim-option-unavailable';li.setAttribute('role','presentation');
button.className = 'icon icon-sim';button.setAttribute('role','option');button.disabled = true;
button.textContent = label('Carte SIM', 'SIM card');
error.className = 'error-message';error.textContent = unavailable();
localize(button, 'Carte SIM', 'SIM card');
error.className = 'error-message';localize(error, 'Cette fonction n’est pas encore disponible sur cette plateforme.', 'This feature is not available on this platform yet.');
li.append(button,error);list.prepend(li);
}
const item = document.getElementById('import-sd-option');
@ -62,7 +86,7 @@
if(item.classList.contains('disabled')) item.classList.remove('disabled');
const span = item.querySelector('span');
if(span && span.dataset.l10nId === 'importMemoryCard') {
span.removeAttribute('data-l10n-id');span.textContent = label('Fichier de contacts', 'Contact file');
span.removeAttribute('data-l10n-id');localize(span, 'Fichier de contacts', 'Contact file');
}
const error = item.querySelector('p');
if(error && !error.hidden) error.hidden = true;
@ -79,6 +103,17 @@
}, true);
}
if (app === 'sms') {
let mmsAvailable=false;
VulpesCompat.call('platform.capabilities',{}).then(c=>{mmsAvailable=c.mms===true;}).catch(()=>{});
// Keep the composer intact until a real MMS transport is available.
document.addEventListener('click', event => {
if (mmsAvailable || !event.target.closest('#messages-send-button') || window.Compose?.type !== 'mms') return;
event.preventDefault(); event.stopImmediatePropagation();
notice(label('L’envoi de MMS n’est pas encore disponible dans cette version. Votre message et ses pièces jointes sont conservés.',
'MMS sending is not yet available in this version. Your message and attachments have been kept.'));
}, true);
}
if (app === 'sms' && window.VulpesPlatform === 'android') {
document.addEventListener('click', event => {
if (!event.target.closest('#messages-send-button')) return;
@ -103,7 +138,7 @@
if (!header || header.querySelector('[data-vulpes-import]')) return;
const action = document.createElement('button');
action.dataset.vulpesImport = ''; action.dataset.icon = 'add';
action.setAttribute('aria-label', label('Importer des fichiers', 'Import files'));
localize(action, 'Importer des fichiers', 'Import files', 'aria-label');
action.onclick = () => VulpesCompat.call('platform.pick',{type:mediaType}).catch(error => {
if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable());
});
@ -134,31 +169,79 @@
const unsupported = {
wifi: !navigator.mozWifiManager,
bluetooth: !navigator.mozBluetooth,
carrier: !navigator.mozMobileConnections,
call: !navigator.mozMobileConnections,
messaging: !navigator.mozMobileConnections,
carrier: !window.VulpesMobileSettings,
call: !window.VulpesMobileSettings,
messaging: !navigator.mozMobileConnections?.length,
'sim-manager': !navigator.mozIccManager,
simpin: !navigator.mozIccManager,
hotspot: !navigator.mozWifiManager,
simpin: true,
'wifi-manageCertificates': true,
'wifi-selectCertificateFile': true,
hotspot: !window.VulpesWifiSettings,
downloads: capabilities.platform === 'android',
'wifi-manageNetworks': !navigator.mozWifiManager,
'messaging-details': !navigator.mozMobileConnections?.length,
'operator-settings': !window.VulpesMobileSettings,
'carrier-detail': !window.VulpesMobileSettings,
'apn-settings': !window.VulpesMobileSettings,
'apn-list': !window.VulpesMobileSettings,
'apn-editor': !window.VulpesMobileSettings,
'call-forwarding': !window.VulpesMobileSettings,
'call-cbSettings': true,
'call-fdnSettings': true,
'call-fdnList': true,
'developer-service-workers': true,
'developer-hud': true,
'accessibility-screenreader': true,
crashReports: true,
improveBrowserOS: true,
'improveBrowserOS-sendFeedback': true,
'improveBrowserOS-chooseFeedback': true,
'full-developer-mode': true,
findmydevice: true,
fxa: true,
fxsync: true,
addons: true,
mediaStorage: !navigator.getDeviceStorages,
applicationStorage: !navigator.getDeviceStorage,
usbStorage: !navigator.getDeviceStorages,
usbStorage: true,
};
function preservePanel(panel) {
const content = panel.querySelector(':scope > div');
if (!content || content.querySelector('[data-vulpes-unavailable]')) return;
const content = panel.querySelector(':scope > div') || panel;
if (content.querySelector('[data-vulpes-unavailable]')) return;
const explanation = document.createElement('p');
explanation.className = 'explanation'; explanation.dataset.vulpesUnavailable = '';
explanation.setAttribute('role', 'status'); explanation.textContent = unavailable();
content.prepend(explanation);
explanation.setAttribute('role', 'status'); localize(explanation, 'Cette fonction n’est pas encore disponible sur cette plateforme.', 'This feature is not available on this platform yet.');
const header=content===panel ? panel.querySelector(':scope > gaia-header') : null;
if(header)header.after(explanation);else content.prepend(explanation);
const serviceNotes = {
fxa: ['La connexion intégrée au compte Mozilla doit être portée vers l’authentification actuelle. Ouvrir le site du compte ne connecte pas encore Vulpes.', 'Integrated Mozilla account sign-in needs the current authentication protocol. Opening the account website does not sign Vulpes in.'],
fxsync: ['La synchronisation nécessite la connexion au compte Mozilla et un adaptateur pour les données Vulpes. Elle n’est pas encore raccordée.', 'Sync requires Mozilla account sign-in and an adapter for Vulpes data. It is not connected yet.'],
findmydevice: ['La localisation distante nécessite un service Vulpes sécurisé et associé à votre compte. Aucun appareil n’est actuellement suivi.', 'Remote device location requires a secure Vulpes service linked to your account. No device is currently tracked.'],
addons: ['Ce panneau gérait les extensions des applications Firefox OS, pas les extensions du navigateur Firefox. Leur moteur d’exécution reste à porter.', 'This panel managed Firefox OS app add-ons, not Firefox browser extensions. Their runtime still needs to be ported.'],
usbStorage: ['Le transfert de fichiers USB n’est pas encore raccordé. La connexion USB de maintenance reste disponible.', 'USB file transfer is not connected yet. The maintenance USB connection remains available.']
};
if(serviceNotes[panel.id]) localize(explanation,...serviceNotes[panel.id]);
if (panel.id === 'wifi-manageCertificates' || panel.id === 'wifi-selectCertificateFile') localize(explanation, 'Les certificats Wi-Fi et les réseaux WPA-Enterprise ne sont pas encore pris en charge par cet adaptateur.', 'Wi-Fi certificates and WPA-Enterprise networks are not supported by this adapter yet.');
if (panel.id === 'call' && window.VulpesNativeTelephony) {
localize(explanation, 'Les appels fonctionnent. Le renvoi, le double appel et le masquage du numéro ne sont pas encore configurables ici.', 'Calling is available. Call forwarding, call waiting and caller ID cannot be configured here yet.');
for (const item of content.querySelectorAll('li[hidden]')) item.hidden = false;
}
if(panel.id==='improveBrowserOS') {
const list=content.querySelector('ul');
for(const child of [...content.children]) if(child!==list && child!==explanation)child.hidden=true;
const anchor=list.querySelector('a');anchor.href='https://nnsprod.com/git/oversu/VulpesOS/issues';
const title=anchor.querySelector('span');title.removeAttribute('data-l10n-id');
localize(title,'Signaler un problème ou proposer une idée','Report a problem or suggest an idea');
localize(explanation,'Les retours sont recueillis sur le dépôt Vulpes OS. Aucun rapport n’est envoyé automatiquement.','Feedback is collected on the Vulpes OS repository. No report is sent automatically.');
anchor.addEventListener('click',event=>{event.preventDefault();new MozActivity({name:'view',data:{type:'url',url:anchor.href}});});
return;
}
for (const control of content.querySelectorAll('button,input,select,gaia-switch,gaia-checkbox,gaia-radio')) {
control.disabled = true; control.setAttribute('disabled', '');
}
for (const anchor of content.querySelectorAll('a')) {
const href=anchor.getAttribute('href');
if(href && href!=='#' && href.startsWith('#') && unsupported[href.slice(1)]) continue;
anchor.setAttribute('aria-disabled', 'true');
anchor.addEventListener('click', event => {event.preventDefault();event.stopImmediatePropagation();}, true);
}
@ -168,9 +251,19 @@
const install = setInterval(() => {
if (typeof window.require !== 'function' || !require.defined?.('modules/settings_panel') || !require.defined?.('modules/panel_cache')) return;
clearInterval(install);
require(['modules/panel_cache', 'modules/settings_panel'], (cache, SettingsPanel) => {
require(['modules/panel_cache', 'modules/settings_panel', 'modules/dialog_panel'], (cache, SettingsPanel, DialogPanel) => {
const original = cache.get.bind(cache), panels = new Map();
cache.get = (id, callback) => {
const dialog = window.VulpesWifiSettings?.dialogs[id];
const native = window.VulpesMobileSettings?.panels[id] || window.VulpesWifiSettings?.panels[id];
if(dialog) {
if(!panels.has(id))panels.set(id,DialogPanel(dialog()));
callback?.(panels.get(id));return;
}
if(native) {
if(!panels.has(id))panels.set(id,SettingsPanel(native()));
callback?.(panels.get(id));return;
}
if (!unsupported[id]) return original(id, callback);
if (!panels.has(id)) panels.set(id, SettingsPanel({onInit:preservePanel, onBeforeShow:preservePanel}));
callback?.(panels.get(id));
@ -179,26 +272,70 @@
}, 100);
function restore() {
batteryText();
if (window.VulpesMobileSettings) {
const panel = document.querySelector('#messaging > div');
if (panel && !panel.querySelector('[data-vulpes-message-limit]')) {
const p = document.createElement('p'); p.className = 'explanation'; p.dataset.vulpesMessageLimit = '';
localize(p, 'Les accusés de réception SMS sont reliés au modem. Les autres options grisées ne sont pas encore prises en charge.', 'SMS delivery reports are connected to the modem. The other greyed-out options are not supported yet.'); panel.prepend(p);
}
if (panel) VulpesMobileSettings.messaging(panel);
for (const control of panel?.querySelectorAll('gaia-switch,select') || []) {
if (control.dataset.vulpesSupported === 'true') continue;
if (!control.hasAttribute('disabled')) control.setAttribute('disabled','');
if (control.disabled !== true) control.disabled = true;
}
}
const clockPanel=document.getElementById('dateTime');
if(clockPanel?.querySelector('.date-picker') && !navigator.mozTime) {
for(const control of clockPanel.querySelectorAll('.date-picker,.time-picker,.timezone-picker select')) if(!control.disabled)control.disabled=true;
if(!clockPanel.querySelector('[data-vulpes-clock-host]')) {
const row=document.createElement('p');row.className='explanation';row.dataset.vulpesClockHost='';
localize(row,'La date et le fuseau horaire sont gérés par le système hôte. Le format de l’heure reste réglable ici.', 'Date and timezone are managed by the host system. The time format can still be changed here.');
clockPanel.querySelector(':scope > div').prepend(row);
}
}
for(const id of ['mediaStorage','applicationStorage']) {
const content=document.querySelector('#'+id+' > div');
if(content && !content.querySelector('[data-vulpes-storage-scope]')) {
const p=document.createElement('p');p.className='explanation';p.dataset.vulpesStorageScope='';
localize(p,'Espace utilisé par Vulpes et espace libre sur le volume hôte. Les données des autres applications du système ne sont pas incluses.', 'Vulpes storage usage and free space on the host volume. Other host applications are not included.');
content.prepend(p);
}
}
const root = document.querySelector('#root');
if (root) for (const id of ['wifi','bluetooth','carrier','call','messaging','sim-manager','simpin',
'findmydevice','addons','mediaStorage','applicationStorage']) {
const li = root.querySelector('a[href="#' + id + '"]')?.closest('li');
if (li?.hidden) li.hidden = false;
if (li?.hasAttribute('aria-disabled')) li.removeAttribute('aria-disabled');
const anchor = li?.querySelector('a');
if (anchor?.hasAttribute('aria-disabled')) anchor.removeAttribute('aria-disabled');
}
if (capabilities.platform === 'android' && root && !document.getElementById('vulpes-android-settings')) {
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
a.id = 'vulpes-android-settings'; a.className = 'menu-item'; a.href = '#';
a.textContent = label('Paramètres Android', 'Android settings');
localize(a, 'Paramètres Android', 'Android settings');
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'root'}).catch(() => notice(unavailable()));};
li.append(a); ul.append(li); root.querySelector(':scope > div')?.append(ul);
}
if (capabilities.platform === 'android') {
const keyboard=document.querySelector('#keyboard > div');
if(keyboard && !document.getElementById('vulpes-keyboard-mode')) {
const ul=document.createElement('ul'),li=document.createElement('li'),control=document.createElement('gaia-switch'),title=document.createElement('label');
control.id='vulpes-keyboard-mode';
localize(title,'Clavier Gaia (expérimental)','Gaia keyboard (experimental)');
control.append(title);li.append(control);ul.append(li);keyboard.prepend(ul);
VulpesCompat.call('input.config',{}).then(enabled=>{control.checked=enabled;});
control.addEventListener('change',()=>navigator.mozSettings.createLock().set({'keyboard.gaia.enabled':control.checked}).catch(console.error));
}
}
if (capabilities.platform === 'android') {
const homescreens = document.querySelector('#homescreens > div');
if (homescreens && !document.getElementById('vulpes-default-home')) {
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
a.id = 'vulpes-default-home'; a.className = 'menu-item'; a.href = '#';
a.textContent = label('Accueil Android par défaut', 'Default Android home');
localize(a, 'Accueil Android par défaut', 'Default Android home');
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'home'}).catch(() => notice(unavailable()));};
li.append(a);ul.append(li);homescreens.append(ul);
}
@ -213,12 +350,12 @@
const legal = document.querySelector('#about-legal > div');
if (legal && !document.getElementById('vulpes-credits')) {
const p = document.createElement('p'); p.id = 'vulpes-credits'; p.className = 'explanation';
p.textContent = label('Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.',
localize(p, 'Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.',
'Vulpes OS builds on Mozilla Gaia and Gecko and the work of their contributors. Capyloon is among the projects inspiring this continuation. Original licenses and attributions are retained.');
legal.append(p);
}
}
new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden']});
new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden','aria-disabled','disabled']});
restore();
}
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => ready().catch(console.error), {once:true});

206
host/gaia-select.js Normal file
View file

@ -0,0 +1,206 @@
// Gaia's value-selector markup and stylesheet, without a browser-chrome popup.
(() => {
let active;
function close(commit) {
if (!active) return;
const {select, wrapper, chosen} = active;
active = null;
if (commit && select.isConnected && !select.disabled) {
const before = [...select.options].map(o => o.selected).join(',');
[...select.options].forEach((o, i) => { o.selected = chosen.has(i); });
if (before !== [...select.options].map(o => o.selected).join(',')) {
select.dispatchEvent(new Event('input', {bubbles:true}));
select.dispatchEvent(new Event('change', {bubbles:true}));
}
}
wrapper.remove();
// Several original panels commit modem settings on blur.
select.blur();
}
function open(select) {
if (select.disabled || select.closest('[aria-disabled="true"]')) return;
close(false);
if (!document.querySelector('[data-vulpes-select-style]')) {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.dataset.vulpesSelectStyle = '';
link.href = `${location.protocol}//system.localhost:${location.port}/shared/style/value_selector.css`;
document.head.append(link);
}
const wrapper = document.createElement('div'); wrapper.className = 'value-selector';
wrapper.id = 'vulpes-value-selector-layer';
const form = document.createElement('form');
form.id = 'vulpes-value-selector'; form.role = 'dialog';
form.dataset.type = 'value-selector'; form.setAttribute('aria-modal', 'true');
form.innerHTML = '<section><h1 id="vulpes-select-title"></h1><ol role="listbox"></ol></section><menu class="value-selector-buttons"><button type="button" data-action="cancel"></button><button type="submit" class="affirmative" data-action="confirm">OK</button></menu>';
form.setAttribute('aria-labelledby', 'vulpes-select-title');
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
form.querySelector('[data-action="cancel"]').textContent = fr ? 'Annuler' : 'Cancel';
form.querySelector('h1').textContent = document.getElementById(select.getAttribute('aria-labelledby'))?.textContent || select.closest('li')?.querySelector('span[data-l10n-id],label,p')?.textContent || (fr ? 'Choisir une option' : 'Choose an option');
const chosen = new Set([...select.options].flatMap((o,i) => o.selected ? [i] : []));
const list = form.querySelector('ol');
if (select.multiple) list.setAttribute('aria-multiselectable', 'true');
[...select.children].forEach(child => {
if (child.tagName === 'OPTGROUP') {
const heading = document.createElement('li'); heading.role = 'subheader';
heading.textContent = child.label; list.append(heading);
}
for (const option of child.tagName === 'OPTGROUP' ? child.children : [child]) {
const i = [...select.options].indexOf(option);
if (i < 0 || option.hidden) continue;
const li = document.createElement('li'); li.role = 'option'; li.tabIndex = 0;
li.dataset.index = i; li.dataset.value = option.value;
li.setAttribute('aria-selected', String(chosen.has(i)));
const disabled = option.disabled || option.parentElement.disabled === true;
li.setAttribute('aria-disabled', String(disabled));
const label = document.createElement('label'), span = document.createElement('span');
label.role = 'presentation'; span.textContent = option.textContent;
label.append(span); li.append(label); list.append(li);
const pick = () => {
if (disabled) return;
if (!select.multiple) chosen.clear();
if (select.multiple && chosen.has(i)) chosen.delete(i); else chosen.add(i);
list.querySelectorAll('[role="option"]').forEach(n => n.setAttribute('aria-selected', String(chosen.has(Number(n.dataset.index)))));
};
li.onclick = pick;
li.onkeydown = event => { if (['Enter',' '].includes(event.key)) {event.preventDefault();pick();} };
}
});
active = {select, form, wrapper, chosen};
select.focus({preventScroll:true});
form.onsubmit = event => {event.preventDefault();close(true);};
form.querySelector('[data-action="cancel"]').onclick = () => close(false);
wrapper.append(form); document.body.append(wrapper);
}
// Gecko's embedded native select may consume the compatibility click.
// Open on a completed tap too, without treating a scroll or a hold as a tap.
let gesture, recentTap;
addEventListener('touchstart', event => {
const select=event.target;
if (select.tagName !== 'SELECT' || select.size > 1 || event.touches.length !== 1) {gesture=null;return;}
const point=event.touches[0];
gesture={select,id:point.identifier,x:point.clientX,y:point.clientY,moved:false,at:performance.now()};
},{capture:true,passive:true});
addEventListener('touchmove', event => {
if (!gesture) return;
const point=[...event.touches].find(p=>p.identifier===gesture.id);
if (!point || event.touches.length!==1 || Math.hypot(point.clientX-gesture.x,point.clientY-gesture.y)>10) gesture.moved=true;
},{capture:true,passive:true});
addEventListener('touchcancel',()=>{gesture=null;},{capture:true,passive:true});
addEventListener('touchend', event => {
const tap=gesture;gesture=null;
if (!tap || tap.moved || performance.now()-tap.at>600 || event.touches.length) return;
const point=[...event.changedTouches].find(p=>p.identifier===tap.id);
if (!point || Math.hypot(point.clientX-tap.x,point.clientY-tap.y)>10) return;
event.preventDefault();event.stopImmediatePropagation();
recentTap={select:tap.select,at:performance.now()};open(tap.select);
},{capture:true,passive:false});
// Cancel the browser-chrome popup. Mouse and keyboard activation use Gaia too.
for (const type of ['mousedown','pointerdown']) addEventListener(type, event => {
if (event.target.tagName === 'SELECT' && event.target.size <= 1) event.preventDefault();
}, true);
addEventListener('click', event => {
if (event.target.tagName !== 'SELECT' || event.target.size > 1) return;
event.preventDefault(); event.stopImmediatePropagation();
if (recentTap?.select===event.target && performance.now()-recentTap.at<700) return;
open(event.target);
}, true);
addEventListener('keydown', event => {
if (active && event.key === 'Escape') {event.preventDefault();close(false);}
else if (event.target.tagName === 'SELECT' && [' ','Enter','ArrowDown'].includes(event.key)) {
event.preventDefault();open(event.target);
}
}, true);
addEventListener('pagehide', () => close(false));
document.addEventListener('visibilitychange', () => {if (document.hidden) close(false);});
addEventListener('panelready', event => {
// A late transition event for the same panel must not dismiss a menu
// opened immediately after navigation. Leaving the panel still cancels it.
if (active && event.detail?.current !== '#' + active.select.closest('section')?.id) close(false);
});
})();
// Date/time pickers still belong to Gaia System. Embedded Gecko does not
// provide the retired mozInputMethod value-selector events for these inputs.
(() => {
const systemOrigin = 'http://system.localhost:8765';
if (location.origin === systemOrigin) {
addEventListener('message', event => {
if (event.data?.type === 'vulpes-keyboard-close' && event.origin === 'http://keyboard.localhost:8765') {
const frame=[...document.querySelectorAll('iframe')].find(f=>f.contentWindow===event.source && f.src.startsWith(event.origin+'/settings.html'));
if(frame) {
const keyboard=Service.query('getApp',event.origin);
const close=()=>frame.isConnected && frame.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
// Closing a still-transitioning background window can send Gaia
// Home over the application which has just replaced it.
if(keyboard?.isActive())keyboard.element.addEventListener('_closed',close,{once:true});
else close();
}
return;
}
const app = window.Service?.query('getActiveApp');
if (!app || event.source !== app.iframe?.contentWindow || event.origin !== app.origin) return;
if (event.data?.type !== 'vulpes-datetime-open' || !['date','time'].includes(event.data.inputType)) return;
const picker = app.valueSelector;
if (!picker) return;
const previous = picker._im;
const reply = value => event.source.postMessage({type:'vulpes-datetime-result', id:event.data.id, value}, event.origin);
picker._im = {
setValue: value => reply(value),
removeFocus() { reply(null); picker._im = previous; }
};
picker.handleEvent({type:'_inputfocus', detail:{inputType:event.data.inputType,
value:String(event.data.value || '').slice(0,32), min:event.data.min, max:event.data.max}});
});
return;
}
if (location.hostname === 'keyboard.localhost' && /settings/.test(location.pathname)) {
window.close = () => parent.postMessage({type:'vulpes-keyboard-close'}, systemOrigin);
}
let pending, sequence=0;
addEventListener('click', event => {
const input=event.target;
if (input.tagName !== 'INPUT' || !['date','time'].includes(input.type) || input.disabled || input.readOnly) return;
event.preventDefault();event.stopImmediatePropagation();
pending={input,id:++sequence};
parent.postMessage({type:'vulpes-datetime-open',id:sequence,inputType:input.type,
value:input.value,min:input.min,max:input.max},systemOrigin);
},true);
addEventListener('message', event => {
if(event.source!==parent || event.origin!==systemOrigin || event.data?.type!=='vulpes-datetime-result' || event.data.id!==pending?.id)return;
const {input}=pending;pending=null;
if(event.data.value!==null && input.isConnected && !input.disabled) {
input.value=event.data.value;
if(input.value) {
input.dispatchEvent(new Event('input',{bubbles:true}));
input.dispatchEvent(new Event('change',{bubbles:true}));
}
}
input.blur();
});
})();
// Modern native select text metrics differ from Gaia's 4rem button box.
// Keep the real select for interaction and accessibility, and paint its label
// in a regular line box so Fira's descenders are not clipped by the control.
(() => {
const labels=new WeakMap();
function refresh() {
for(const select of document.querySelectorAll('.button select,.bb-button select')) {
if(select.multiple || select.size>1)continue;
let label=labels.get(select);
if(!label) {
label=document.createElement('span');label.className='vulpes-select-label';
label.setAttribute('aria-hidden','true');select.after(label);labels.set(select,label);
select.classList.add('vulpes-select-label-owner');
select.addEventListener('change',refresh);
}
const text=select.selectedOptions[0]?.textContent || '';
if(label.textContent!==text)label.textContent=text;
}
}
let queued=false;
new MutationObserver(()=>{if(!queued){queued=true;queueMicrotask(()=>{queued=false;refresh();});}})
.observe(document.documentElement,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['selected','value']});
addEventListener('panelready',refresh);addEventListener('localized',refresh);
refresh();
})();

View file

@ -18,6 +18,21 @@
function unavailable(message) {
window.VulpesGaiaUI?.notice(message);
}
if(location.hostname==='system.localhost') {
const colorKeys=['accessibility.colors.enable','accessibility.colors.invert','accessibility.colors.grayscale','accessibility.colors.contrast'];
const values={};
function colors() {
const screen=document.getElementById('screen');if(!screen)return;
const enabled=values[colorKeys[0]]===true;
const contrast=Number(values[colorKeys[3]])||0;
screen.style.filter=enabled ? `invert(${values[colorKeys[1]]?1:0}) grayscale(${values[colorKeys[2]]?1:0}) contrast(${Math.max(0,Math.min(2,1+contrast))})` : '';
}
for(const key of colorKeys) {
navigator.mozSettings.addObserver(key,e=>{values[key]=e.settingValue;colors();});
navigator.mozSettings.createLock().get(key).then(row=>{values[key]=row[key];colors();});
}
addEventListener('DOMContentLoaded',colors,{once:true});
}
addEventListener('DOMContentLoaded', () => {
if (location.hostname === 'system.localhost' && !window.VulpesPlatform) {
const ids = ['wifi', 'bluetooth', 'data', 'airplane-mode'];
@ -72,6 +87,23 @@
);
}
});
if(['settings.localhost','system.localhost'].includes(location.hostname) && window.VulpesPlatform!=='android') {
const manager=Object.assign(new EventTarget(),{}), downloads=new Map();
manager.getDownloads=async()=>{
const rows=await call('downloads.request',{operation:'list'});
return rows.map(row=>{
let item=downloads.get(row.id);
if(!item) {
item=Object.assign(new EventTarget(),{pause:()=>call('downloads.request',{operation:'pause',id:row.id}),resume:()=>call('downloads.request',{operation:'resume',id:row.id})});
downloads.set(row.id,item);
}
Object.assign(item,row,{vulpesDownload:true,getBlob:()=>call('downloads.request',{operation:'blob',id:row.id}),startTime:new Date(row.startTime)});return item;
});
};
manager.remove=item=>call('downloads.request',{operation:'remove',id:item.id});
manager.clearAllDone=async()=>{for(const item of await manager.getDownloads())if(item.state==='succeeded')await manager.remove(item);};
Object.defineProperty(navigator,'mozDownloadManager',{value:manager});
}
Object.defineProperty(navigator, 'mozAlarms', {
value: {
getAll: () =>
@ -222,6 +254,15 @@
}
window.VulpesCompat.cursor = cursor;
if (location.hostname === 'sms.localhost') {
addEventListener('DOMContentLoaded', () => {
const send = document.getElementById('messages-send-button');
if (!send) return;
send.setAttribute('data-vulpes-keep-input', '');
// Keep the composer focused, including with Android's native keyboard.
// Sending remains a click action, so a cancelled gesture never sends.
for (const type of ['pointerdown', 'mousedown'])
send.addEventListener(type, event => event.preventDefault());
});
Object.defineProperty(navigator, 'mozMobileConnections', { value: Object.freeze([]) });
const messages = new EventTarget();
for (const operation of ['getThreads', 'getMessages'])
@ -242,11 +283,10 @@
'getSegmentInfoForText',
])
messages[operation] = (...args) => request(call('messages.request', { operation, args }));
const dated = value => value && typeof value==='object' && 'timestamp' in value ?
{...value,timestamp:new Date(value.timestamp)} : value;
const dated = raw => { const value=window.VulpesSettingsCodec.decode(raw);return value && typeof value==='object' && 'timestamp' in value ?
{...value,timestamp:new Date(value.timestamp)} : value; };
messages.getMessage = id => request(call('messages.request',{operation:'getMessage',args:[id]}).then(dated));
messages.send = (recipients,body,options) => {
const send = recipient => {
const submit = (operation,args) => {
let id, settle;
const early = new Map();
const terminal = new Promise((resolve,reject)=>{settle=message=>{
@ -260,7 +300,7 @@
else if(id===undefined && early.size<100) early.set(message.id,message);
};
addEventListener('vulpes-service-event',listener);
const result=call('messages.request',{operation:'send',args:[recipient,body,options]}).then(message=>{
const result=call('messages.request',{operation,args}).then(message=>{
if(message.delivery!=='sending') return dated(message);
id=message.id;
if(early.has(id)) settle(early.get(id));
@ -268,9 +308,12 @@
return terminal;
}).finally(()=>removeEventListener('vulpes-service-event',listener));
return request(result);
};
};
messages.send = (recipients,body,options) => {
const send=recipient=>submit('send',[recipient,body,options]);
return Array.isArray(recipients) ? recipients.map(send) : send(recipients);
};
messages.sendMMS = (message,options) => submit('sendMMS',[message,options]);
addEventListener('vulpes-service-event',({detail})=>{
const type = {'sms-received':'received','sms-sending':'sending','sms-sent':'sent','sms-failed':'failed'}[detail.type];
if (!type) return;
@ -281,17 +324,22 @@
Object.defineProperty(navigator, 'mozMobileMessage', { value: messages });
}
if (
['gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
['settings', 'gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
) {
const stores = new Map();
function storage(type) {
if (!['pictures', 'music', 'videos'].includes(type)) return null;
if (!['pictures', 'music', 'videos', ...(location.hostname==='settings.localhost'?['apps','sdcard']:[])].includes(type)) return null;
if (stores.has(type)) return stores.get(type);
const s = new EventTarget();
s.storageName = 'sdcard';
s.default = true;
s.canBeMounted = false;
const run = (operation, params = {}) => call('media.request', { type, operation, ...params });
const run = (operation, params = {}) => {
if(!['apps','sdcard'].includes(type))return call('media.request', { type, operation, ...params });
if(operation==='available')return call('storage.stats',{}).then(()=>'available');
if(['freeSpace','usedSpace'].includes(operation))return call('storage.stats',{}).then(stats=>operation==='freeSpace'?stats.free:stats[type]);
return Promise.reject(new DOMException('Read-only storage statistics','NotSupportedError'));
};
for (const op of ['available', 'freeSpace', 'usedSpace']) s[op] = () => request(run(op));
s.get = (name) =>
request(

View file

@ -109,6 +109,14 @@
// Only a custom element's type selector names the scope root. A div
// scope still contains ordinary div descendants (notably Lockscreen).
if (owner.localName.includes('-')) text = text.replace(tag, ':scope');
if (owner.id) {
const rootId = '#' + CSS.escape(owner.id);
const pattern = new RegExp(rootId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w-])', 'g');
// Old scoped sheets could style their owner by ID. Modern @scope
// requires :scope for that element (for example the volume HUD).
text = text.replace(/([^{}]+)\{/g, (rule, selector) =>
selector.replace(pattern, ':scope') + '{');
}
text = text.replace(/\.-(?:host|content)(?![\w-])/g, (match) => ':scope' + match);
style.textContent = '@scope ([data-vulpes-scope="' + id + '"]) {' + text + '}';
style.removeAttribute('scoped');

105
host/gaia-theme.css Normal file
View file

@ -0,0 +1,105 @@
/* Keep Gaia geometry, icons and media intact; change the surfaces and ink. */
:root[data-vulpes-theme="dark"] {
color-scheme: dark;
--vulpes-surface: #18232e;
--vulpes-raised: #253442;
--vulpes-text: #f1f5f9;
--vulpes-muted: #b8c8d8;
--vulpes-line: #526272;
--link-color: #79ceff;
--header-background: var(--vulpes-raised);
--header-color: var(--vulpes-text);
--header-title-color: var(--vulpes-text);
--header-button-color: var(--vulpes-text);
--header-icon-color: var(--vulpes-text);
--header-action-button-color: #79ceff;
--header-disabled-button-color: var(--vulpes-muted);
}
:root[data-vulpes-theme="dark"]:is([data-vulpes-app="settings"],
[data-vulpes-app="communications"], [data-vulpes-app="sms"],
[data-vulpes-app="email"], [data-vulpes-app="calendar"],
[data-vulpes-app="ringtones"]) {
--background: var(--vulpes-surface);
--text-color: var(--vulpes-text);
--title-color: var(--vulpes-text);
--label-color: var(--vulpes-text);
background: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"] gaia-header {
--header-background: var(--vulpes-raised) !important;
--header-title-color: var(--vulpes-text) !important;
--header-action-button-color: #79ceff !important;
}
#display #vulpes-theme-row {
display: block !important;
min-height: 10rem;
height: auto !important;
padding-bottom: 1rem;
}
#vulpes-theme-row > p {
height: auto;
line-height: 2.4rem;
padding: 1rem 1.5rem;
transform: none;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] h2 {
background-color: var(--vulpes-surface) !important;
}
:root[data-vulpes-theme="dark"]:is([data-vulpes-app="settings"],
[data-vulpes-app="communications"], [data-vulpes-app="sms"],
[data-vulpes-app="email"], [data-vulpes-app="calendar"],
[data-vulpes-app="ringtones"]) :is(body, section, [role="region"],
[data-type="list"], .view, .card, #main, #messages-container, #thread-list,
#contacts-list, #view-contacts-list, #no-contacts, #call-log-container, #messages-compose-form) {
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"] :is(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), textarea, select, [contenteditable="true"]) {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
border-color: var(--vulpes-line) !important;
caret-color: #79ceff;
}
:root[data-vulpes-theme="dark"] :is(input, textarea)::placeholder { color: var(--vulpes-muted) !important; }
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] :is(li, li > a, li > a > span, li > p, li > small, label, .explanation, h1, h2) {
color: var(--vulpes-text) !important;
border-color: var(--vulpes-line) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] :is(ul, header, .button, button:not(.danger)) {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] :is(.contact-item, .contact-item a, .contact-item p, #call-log-container li, #call-log-container a, #call-log-container p, .phone-number, #keypad-phone-number, .keypad-key) {
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #phone-number-view-container {
background-color: var(--vulpes-raised) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] :is(.keypad-subicon, [role="tab"]) {
color: #79ceff !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] [role="tab"][aria-selected="true"] {
background-color: var(--vulpes-raised) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #no-contacts :is(p, .info) {
color: var(--vulpes-muted) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #no-contacts {
background-blend-mode: soft-light;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="sms"] :is(.threadlist-item, .threadlist-item a, .threadlist-item p, .time, .name, .body-text) {
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="sms"] .bubble {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"] :is([data-type="confirm"], [data-type="action"]) :is(section, menu, header, p) {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="system"] :is(#utility-tray, #notifications-container) {
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}

58
host/gaia-theme.js Normal file
View file

@ -0,0 +1,58 @@
// Shared appearance preference; external browser pages are not injected.
(() => {
const key = 'vulpes.theme';
const system = matchMedia('(prefers-color-scheme: dark)');
let mode = 'light', select;
function apply(value) {
mode = ['light', 'dark', 'system'].includes(value) ? value : 'light';
document.documentElement.dataset.vulpesTheme =
mode === 'system' ? (system.matches ? 'dark' : 'light') : mode;
if (select) select.value = mode;
}
system.addEventListener('change', () => apply(mode));
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
});
VulpesCompat.call('appearance.current', {}).then(apply).catch(console.error);
if (location.hostname !== 'settings.localhost') return;
function label() {
const lang = document.documentElement.lang || navigator.language;
const labels = lang.startsWith('fr') ? ['Apparence', 'Clair', 'Sombre', 'Selon le système'] :
lang.startsWith('el') ? ['Εμφάνιση', 'Φωτεινό', 'Σκοτεινό', 'Σύμφωνα με το σύστημα'] :
['Appearance', 'Light', 'Dark', 'Follow system'];
const title = document.getElementById('vulpes-theme-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-theme-row';
title.id = 'vulpes-theme-label';
const wrapper = document.createElement('span');
wrapper.className = 'button icon icon-dialog';
select = document.createElement('select');
select.id = 'vulpes-theme';
select.setAttribute('aria-labelledby', title.id);
for (const value of ['light', 'dark', 'system']) {
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();
})();

View file

@ -6,7 +6,7 @@
enabled:false, connection:{status:'disconnected',network:null},
connectionInformation:null, macAddress:'',
});
let networks = [];
let networks = [], connectionInfo = {};
function emit(type, values = {}) {
const event = Object.assign(new Event(type), values);
manager.dispatchEvent(event); manager['on' + type]?.(event);
@ -21,7 +21,7 @@
const status = network ? 'connected' : 'disconnected';
const changed = manager.connection.status !== status || manager.connection.network?.ssid !== network?.ssid;
manager.connection = {status, network};
manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
manager.connectionInformation = network ? {...connectionInfo,relSignalStrength:network.relSignalStrength} : null;
if (changed) emit('statuschange', manager.connection);
// Gaia's signal listeners require the associated network. A scan while
// disconnected must not announce connection information for a missing AP.
@ -36,17 +36,23 @@
async function scanNetworks() {
const result = await call('platform.wifi',{operation:'scan'});
power(result.enabled !== false);
connectionInfo=result.connectionInformation || {};
networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal,
security:n.security === 'open' ? [] : [n.security === 'wpa-psk' ? 'WPA-PSK' : n.security === 'sae' ? 'SAE' : 'WPA-EAP'],
security:n.security === 'open' ? [] : [['wpa-psk','sae'].includes(n.security) ? 'WPA-PSK' : 'WPA-EAP'],
known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId}));
update(networks.find(n => n.connected) || null);
return networks;
}
manager.getNetworks = () => request(scan());
manager.getKnownNetworks = () => request(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}))));
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;});
return request(knownPending);
};
manager.associate = network => request((async () => {
if (network.security.length && network.security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY');
await call('platform.wifi',{operation:'connect',id:network.vulpesId,password:network.psk || ''});
const security=(network.security || []).filter(Boolean);
if (security.length && security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY');
await call('platform.wifi',network.hidden ? {operation:'connectHidden',ssid:network.ssid,security:security.length?'wpa-psk':'open',password:network.psk || ''} : {operation:'connect',id:network.vulpesId,password:network.psk || ''});
await scan(); return network;
})());
manager.forget = network => request(call('platform.wifi',{operation:'forget',id:network.savedId}).then(()=>scan()));
@ -54,6 +60,91 @@
manager.wps = unsupported;
manager.setPowerSavingMode = unsupported;
Object.defineProperty(navigator,'mozWifiManager',{value:manager});
if(location.hostname.startsWith('settings.')) {
const wifi = value => call('platform.wifi',value);
const french = () => (document.documentElement.lang || navigator.language || '').startsWith('fr');
const message = (fr,en) => french() ? fr : en;
const note = panel => {
const p=document.createElement('p');p.className='explanation';p.setAttribute('role','status');
panel.querySelector(':scope > div').prepend(p);return p;
};
function hotspotPanel() {
let panel,toggle,link,status,state,busy=false;
function render() {
toggle.checked=state?.enabled===true;toggle.disabled=busy || !state;
link.setAttribute('aria-disabled',String(busy || !state || state.enabled));
panel.querySelector('#wifi-hotspot-msg').textContent=state?.enabled ? state.ssid : '';
}
async function refresh() {
busy=true;render();
try {state=await wifi({operation:'hotspotStatus'});status.textContent='';
await call('settings.set',{values:{'tethering.wifi.enabled':state.enabled}});
} catch(error) {state=null;status.textContent=message('Impossible de lire le point d’accès Wi-Fi.','Cannot read Wi-Fi hotspot status.');}
finally {busy=false;render();}
}
return {
onInit(element) {
panel=element;toggle=panel.querySelector('#tethering-wifi-enabled');link=panel.querySelector('#hotspot-settings-section a');status=note(panel);
const usb=panel.querySelector('#tethering-usb-enabled');usb.disabled=true;
const explanation=document.createElement('p');explanation.className='explanation';
explanation.textContent=message('Le partage par USB n’est pas encore disponible.','USB tethering is not available yet.');usb.closest('ul').append(explanation);
link.addEventListener('click',event=>{
event.preventDefault();event.stopImmediatePropagation();if(busy || !state || state.enabled)return;
require(['modules/dialog_service'],DialogService=>DialogService.show('hotspot-wifiSettings',{state}).then(refresh));
});
toggle.addEventListener('change',async()=>{
if(busy || !state)return;
if(!state.configured) {toggle.checked=false;status.textContent=message('Configurez d’abord le nom et le mot de passe du point d’accès.','Configure the hotspot name and password first.');return;}
const enabled=toggle.checked;busy=true;render();
status.textContent=message('Modification du partage Wi-Fi…','Changing Wi-Fi sharing…');
try {
if(enabled && (await call('settings.get',{key:'airplaneMode.status'})).value==='enabled')throw Error('AIRPLANE_MODE');
state=await wifi({operation:'hotspotPower',enabled});
await call('settings.set',{values:{'tethering.wifi.enabled':state.enabled}});status.textContent='';
} catch(error) {
try {state=await wifi({operation:'hotspotStatus'});}catch {state=null;}
status.textContent=error.message==='AIRPLANE_MODE' ? message('Désactivez le mode avion avant de partager la connexion.','Turn off airplane mode before sharing the connection.') : message('Le changement a échoué. Vérifiez la connexion réseau.','The change failed. Check the network connection.');
} finally {busy=false;render();}
});
},onBeforeShow:refresh
};
}
function hotspotDialog() {
let panel,ssid,security,password,show,status,busy=false;
function visibility() {
const hidden=security.value==='open';
panel.querySelector('.password').hidden=hidden;panel.querySelector('.password-description').hidden=hidden;
}
return {
onInit(element) {
panel=element;ssid=panel.querySelector('input[type=text]');security=panel.querySelector('select');password=panel.querySelector('input[name=password]');show=panel.querySelector('gaia-checkbox');status=note(panel);
// These credentials belong to NetworkManager, not the global settings database.
for(const control of [ssid,security,password])control.removeAttribute('data-setting');
security.replaceChildren(new Option('WPA2','wpa2-psk'),new Option(message('Ouvert','Open'),'open'));
security.addEventListener('change',visibility);
show.addEventListener('change',()=>{password.type=show.checked?'text':'password';});
},
onBeforeShow(element,options) {
const state=options.state;ssid.value=state.ssid;security.value=state.security;password.value=state.password;
password.type='password';show.checked=false;status.textContent='';visibility();
},
async onSubmit() {
if(busy)throw Error('HOTSPOT_BUSY');
const values={operation:'hotspotSave',ssid:ssid.value,security:security.value,password:password.value};
if(!values.ssid || new TextEncoder().encode(values.ssid).length>32 || (values.security!=='open' && (new TextEncoder().encode(values.password).length<8 || new TextEncoder().encode(values.password).length>63))) {
status.textContent=message('Nom : 1 à 32 octets. Mot de passe WPA2 : 8 à 63 octets.','Name: 1–32 bytes. WPA2 password: 8–63 bytes.');throw Error('HOTSPOT_INVALID');
}
busy=true;const controls=panel.querySelectorAll('input,select,button,gaia-checkbox');controls.forEach(c=>c.disabled=true);
try {await wifi(values);return {};}
catch(error) {status.textContent=message('Impossible d’enregistrer le point d’accès.','Cannot save the hotspot.');throw error;}
finally {busy=false;controls.forEach(c=>c.disabled=false);}
},
onCancel() {if(busy)throw Error('HOTSPOT_BUSY');},
onHide() {password.value='';}
};
}
window.VulpesWifiSettings={panels:{hotspot:hotspotPanel},dialogs:{'hotspot-wifiSettings':hotspotDialog}};
}
addEventListener('DOMContentLoaded', async () => {
try {
const state = await call('platform.wifi',{operation:'status'});
@ -67,6 +158,7 @@
try {
const result=await call('platform.wifi',{operation:'setEnabled',enabled});
power(result.enabled,true);
if(!result.enabled)await call('settings.set',{values:{'tethering.wifi.enabled':false}});
} catch(error) {
await call('settings.set',{values:{'wifi.enabled':manager.enabled}});
console.warn('Wi-Fi radio change failed',error);

View file

@ -86,6 +86,18 @@
try { return await call('platform.' + operation, params); }
catch (error) { failure(error); }
};
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;
}
};
}
function button(id, french, english, action) {
const b = text(document.createElement('button'), french, english);
b.id = id;
@ -145,7 +157,10 @@
event.preventDefault();
event.stopImmediatePropagation();
const number = document.getElementById('phone-number-view')?.value;
if (number) run('dial', {number});
if (number) {
if (window.CallHandler) CallHandler.call(number, 0);
else window.VulpesPlatformDial(number).catch(() => {});
}
}, true);
async function initialize() {
const caps = await capabilities;

View file

@ -30,7 +30,15 @@ window.addEventListener('load', async () => {
const pollKeys = async () => {
try {
const state = await IOUtils.readJSON('/run/tundra-buttons/events.json');
if (state.session !== session) { session = state.session; sequence = state.sequence; }
if (state.session !== session) {
session = state.session;
// Keep events already present when the poller first sees a new
// service session. Setting the sequence to the file's final
// value silently dropped the first physical key presses after a
// Gecko reload.
const first = state.events?.[0]?.id;
sequence = Number.isInteger(first) ? first - 1 : 0;
}
else if (Date.now()/1000-state.recordedAt < 5) {
for (const event of state.events) if (event.id > sequence) {
Runtime.broadcast('hardware-key', {type:event.type}, 'system');
@ -46,7 +54,7 @@ window.addEventListener('load', async () => {
parent: { esModuleURI: 'resource://vulpes/host/actors/VulpesParent.sys.mjs' },
child: {
esModuleURI: 'resource://vulpes/host/actors/VulpesChild.sys.mjs',
events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true} },
events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true}, click:{capture:true} },
},
allFrames: true,
safeForUntrustedWebProcess: true,
@ -89,6 +97,11 @@ window.addEventListener('load', async () => {
browser.style.width = width + 'px';
browser.style.height = height + 'px';
homeButton.style.width = landscape ? '44px' : window.innerWidth + 'px';
// Flush the embedder's layout before notifying the remote document.
// On Wayland its initial viewport can otherwise remain 0x0 until an
// unrelated chrome layout query, leaving Gaia and video unpainted.
browser.getBoundingClientRect();
browser.frameLoader?.requestUpdatePosition?.();
};
window.addEventListener('resize', resize);