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

View file

@ -1,5 +1,7 @@
export function isGaiaActivity(source) {
return source?.name === 'pick' ||
(source?.name === 'moz_configure_window' && source.data?.target === 'device') ||
(source?.name === 'configure' && source.data?.target === 'ringtone') ||
(source?.name === 'dial' && source.data?.type === 'webtelephony/number') ||
(source?.name === 'new' && source.data?.type === 'websms/sms') ||
(['new','update','open'].includes(source?.name) && source.data?.type === 'webcontacts/contact');
@ -14,19 +16,24 @@ function entryPointFor(app, path) {
// Route Gaia's built-in pickers; only the selected provider can finish a request.
export class Activities {
constructor({identity, apps, launch, notify, uuid}) {
Object.assign(this, {identity, apps, launch, notify, uuid});
constructor({identity, apps, launch, notify, uuid, choose}) {
Object.assign(this, {identity, apps, launch, notify, uuid, choose});
this.pending = new Map();
this.listeners = new Map();
this.choices = new Map();
}
route(source, caller) {
const types = [].concat(source?.data?.type || []), name=source?.name;
let id;
if (name === 'pick') {
if (types.some(t=>['ringtone','alerttone'].includes(t))) id='ringtones';
else if (types.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select'].includes(t))) id='communications';
else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=caller?.id==='communications'?'gallery':'wallpaper';
} else if (name === 'dial' && types.includes('webtelephony/number')) id='communications';
else if (types.includes('wallpaper')) id='wallpaper';
else if (types.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select','text/vcard','text/x-vcard','text/directory'].includes(t))) id='communications';
else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=!caller || caller.id==='settings'?'wallpaper':'gallery';
else if (types.some(t=>typeof t==='string' && t.startsWith('audio/'))) id='music';
else if (types.some(t=>typeof t==='string' && t.startsWith('video/'))) id='video';
} else if (name === 'configure' && source.data?.target === 'ringtone') id='ringtones';
else if (name === 'dial' && types.includes('webtelephony/number')) id='communications';
else if (name === 'new' && types.includes('websms/sms')) id='sms';
else if (['new','update','open'].includes(name) && types.includes('webcontacts/contact')) id='communications';
const app = this.apps().find(a=>a.id===id);
@ -48,9 +55,54 @@ export class Activities {
provider(source) { return this.route(source).app; }
start(caller, source) {
const app = this.identity(caller);
if(source?.name==='moz_configure_window' && source.data?.target==='device') {
if(app?.id!=='keyboard')throw Error('PERMISSION_DENIED');
const settings=this.apps().find(a=>a.id==='settings');
if(!settings)throw Error('ACTIVITY_NOT_SUPPORTED');
this.launch(settings,settings.manifest.launch_path || '/index.html',{});
return Promise.resolve(null);
}
if (!app || !['settings','system','homescreen','verticalhome','ringtones','sms','communications','email','calendar'].includes(app.id))
throw Error('PERMISSION_DENIED');
const {app:provider,definition,url,entryPoint}=this.route(source,app);
const types = [].concat(source?.data?.type || []);
if (source?.name==='pick' && types.length>1 && this.choose) {
const routes=[];
for (const type of types) {
try {
const route=this.route({...source,data:{...source.data,type}},app);
if (!routes.some(r=>r.app.id===route.app.id)) routes.push(route);
} catch (error) { if (error.message!=='ACTIVITY_NOT_SUPPORTED') throw error; }
}
if (routes.length>1) {
if (this.choices.size) throw Error('ACTIVITY_BUSY');
const id=this.uuid();
return new Promise((resolve,reject)=>{
this.choices.set(id,{caller,app,source,routes,resolve,reject});
this.choose({id,name:source.name,activityType:types.join(','),
choices:routes.map(r=>{
const icons=r.app.manifest.icons || {};
const icon=icons[128] || icons[60] || Object.values(icons)[0];
return {manifest:r.app.manifestURL,icon:icon?new URL(icon,r.app.origin).href:undefined};
})});
});
}
if (routes.length===1) return this.open(caller,app,source,routes[0]);
}
return this.open(caller,app,source,this.route(source,app));
}
select(peer,{id,value}) {
if (this.identity(peer)?.id!=='system') throw Error('PERMISSION_DENIED');
const row=this.choices.get(id);
if (!row) throw Error('ACTIVITY_CANCELLED');
if (!/^(?:-1|\d+)$/.test(String(value))) throw Error('INVALID_ACTIVITY_CHOICE');
const index=Number(value);
if (index!==-1 && !row.routes[index]) throw Error('INVALID_ACTIVITY_CHOICE');
this.choices.delete(id);
if (index===-1) {row.reject(Error('ACTIVITY_CANCELLED'));return;}
try {this.open(row.caller,row.app,row.source,row.routes[index]).then(row.resolve,row.reject);}
catch(error) {row.reject(error);}
}
open(caller,app,source,{app:provider,definition,url,entryPoint}) {
if ([...this.pending.values()].some(p=>p.provider.id===provider.id)) throw Error('ACTIVITY_BUSY');
const id = this.uuid();
return new Promise((resolve,reject) => {
@ -102,8 +154,13 @@ export class Activities {
if (this.identity(peer)?.id !== 'system') throw Error('PERMISSION_DENIED');
for (const row of this.pending.values()) row.reject(Error('ACTIVITY_CANCELLED'));
this.pending.clear();
for (const row of this.choices.values()) row.reject(Error('ACTIVITY_CANCELLED'));
this.choices.clear();
}
cleanup(peer) {
for (const [id,row] of this.choices) if (row.caller===peer) {
this.choices.delete(id);row.reject(Error('ACTIVITY_CANCELLED'));
}
for (const [app, listener] of this.listeners) if (listener === peer) this.listeners.delete(app);
for (const [id,row] of this.pending) {
if (row.caller === peer || row.delivered === peer) {

68
services/input-editor.mjs Normal file
View file

@ -0,0 +1,68 @@
// Shared editing operations for the desktop actor and Android content bridge.
export class InputEditor {
constructor(win) { this.contentWindow=win; this.document=win.document; this.editing=false; }
focus(element) { this.inputElement=element; }
state() {
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.state();
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.state();
}
}

36
services/input.mjs Normal file
View file

@ -0,0 +1,36 @@
// 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;
}
}

29
services/mms.mjs Normal file
View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: MPL-2.0
// JSON transport for Gaia attachments; blobs are encoded at the app boundary.
export const MMS_MAX_BYTES = 600 * 1024;
export function mmsPayload(message) {
if (!message || !Array.isArray(message.receivers) || !message.receivers.length || message.receivers.length>10 ||
message.receivers.some(n=>typeof n!=='string' || !/^\+?[0-9]{3,20}$/.test(n))) throw Error('INVALID_NUMBER');
const parts=[];
if(message.smil) parts.push({id:'smil.xml',type:'application/smil',data:utf8Base64(message.smil)});
for(const [i,a] of (message.attachments || []).entries()) {
const content=a.content;
if(!content?.__vulpesBlobV1 || typeof content.base64!=='string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(content.base64)) throw Error('INVALID_MMS');
parts.push({id:a.location || a.id?.replace(/[<>]/g,'') || 'attachment-'+i,type:content.type,data:content.base64});
}
if(!parts.length || parts.length>20 || parts.reduce((n,p)=>n+Math.floor(p.data.length*3/4),0)>MMS_MAX_BYTES) throw Error('MMS_TOO_LARGE');
return {recipients:message.receivers,subject:message.subject || '',parts};
}
function utf8Base64(text) {const bytes=new TextEncoder().encode(text);let s='';for(const byte of bytes)s+=String.fromCharCode(byte);return btoa(s);}
export function receivedMms(row) {
let smil='',body='';const attachments=[];
for(const part of row.parts || []) {
if(['application/smil','text/plain'].includes(part.type)) {
const text=new TextDecoder().decode(Uint8Array.from(atob(part.data),c=>c.charCodeAt(0)));
if(part.type==='application/smil') {smil=text;continue;}
body+=text;
}
attachments.push({id:part.id,location:part.id,content:{__vulpesBlobV1:true,type:part.type,base64:part.data}});
}
return {...row,body,smil,attachments,timestamp:row.timestamp || Date.now()};
}

View file

@ -13,6 +13,9 @@ export function authorizePlatform(app, operation, params = {}) {
settings: ['settings', 'system', 'costcontrol'].includes(app.id),
snapshot: ['settings', 'system', 'costcontrol'].includes(app.id),
wifi: ['settings', 'system'].includes(app.id),
mobileSettings: app.id === 'settings',
deviceSettings: app.id === 'settings',
bluetooth: ['settings', 'system', 'bluetooth'].includes(app.id),
notification: !!permissions['desktop-notification'],
capture: app.id === 'camera' && writeMedia('pictures'),
cameraPreview: app.id === 'camera' && writeMedia('pictures'),
@ -30,6 +33,11 @@ export function authorizePlatform(app, operation, params = {}) {
};
if (!Object.hasOwn(allowed, operation)) throw Error('METHOD_NOT_SUPPORTED');
if (!allowed[operation]) throw Error('PERMISSION_DENIED');
if (operation === 'wifi' && ['hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation) && app.id !== 'settings') throw Error('PERMISSION_DENIED');
if (operation === 'mobileSettings' && !['sim','smsc','calls','messages','deliveryReports','network','dataEnabled','roaming','radioMode','setRadioMode','apns','editApn','setCallWaiting','setCallerId','forwarding','setForwarding'].includes(params.action)) throw Error('INVALID_VALUE');
if (operation === 'mobileSettings' && ['deliveryReports','dataEnabled','roaming','setCallWaiting','setForwarding'].includes(params.action) && typeof params.enabled !== 'boolean') throw Error('INVALID_VALUE');
if (operation === 'mobileSettings' && JSON.stringify(params).length > 6144) throw Error('INVALID_VALUE');
if (operation === 'deviceSettings' && (!['clock','setTime','setTimezone','setAutomatic'].includes(params.action) || JSON.stringify(params).length > 1024)) throw Error('INVALID_VALUE');
if (operation === 'cameraPreview' &&
(!['start', 'frame', 'stop', 'focus', 'zoom', 'picture'].includes(params.action) ||
(params.action === 'start' && !['back', 'front'].includes(params.camera))))
@ -47,7 +55,7 @@ export function authorizePlatform(app, operation, params = {}) {
throw Error('INVALID_VALUE');
}
if (operation === 'ackCallHistory' && (!Array.isArray(params.ids) || params.ids.length > 500 || params.ids.some(id => typeof id !== 'string' || !/^\d{1,20}$/.test(id)))) throw Error('INVALID_VALUE');
if (operation === 'wifi' && JSON.stringify(params).length > 4096)
if (['wifi', 'bluetooth'].includes(operation) && JSON.stringify(params).length > 4096)
throw Error('QuotaExceededError');
if (operation === 'notification' && JSON.stringify(params).length > 65536)
throw Error('QuotaExceededError');