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:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
|
|
@ -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
68
services/input-editor.mjs
Normal 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
36
services/input.mjs
Normal 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
29
services/mms.mjs
Normal 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()};
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue