Release Vulpes OS 2.7-preview.22

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

View file

@ -1,5 +1,5 @@
export function isGaiaActivity(source) {
return source?.name === 'pick' ||
return source?.name === 'pick' || source?.name === 'share' ||
(source?.name === 'moz_configure_window' && source.data?.target === 'device') ||
(source?.name === 'configure' && source.data?.target === 'ringtone') ||
(source?.name === 'dial' && source.data?.type === 'webtelephony/number') ||
@ -22,9 +22,9 @@ export class Activities {
this.listeners = new Map();
this.choices = new Map();
}
route(source, caller) {
route(source, caller, shareProvider) {
const types = [].concat(source?.data?.type || []), name=source?.name;
let id;
let id = name === 'share' && ['sms','email'].includes(shareProvider) ? shareProvider : undefined;
if (name === 'pick') {
if (types.some(t=>['ringtone','alerttone'].includes(t))) id='ringtones';
else if (types.includes('wallpaper')) id='wallpaper';
@ -41,14 +41,16 @@ export class Activities {
const definition = candidates.find(d=>{
const filter=d.filters?.type;
const accepted=[].concat(filter?.value || filter || []);
return !accepted.length || types.some(t=>accepted.includes(t));
const matches = !accepted.length || types.some(t=>typeof t==='string' && accepted.some(a=>a===t || (typeof a==='string' && a.endsWith('/*') && t.startsWith(a.slice(0,-1)))));
const count=source.data?.blobs?.length || source.data?.number || 0;
return matches && (!d.filters?.number?.max || count<=d.filters.number.max);
});
if (!app || !definition?.href) throw Error('ACTIVITY_NOT_SUPPORTED');
const url=new URL(definition.href,app.origin);
if (url.origin!==new URL(app.origin).origin) throw Error('ACTIVITY_NOT_SUPPORTED');
// SMS handles the activity after startup; its old manifest fragment is
// not a Navigation view in the packaged Gaia build.
if (id==='sms' && name==='new') url.hash='';
if (id==='sms' && ['new','share'].includes(name)) url.hash='';
const entryPoint=entryPointFor(app,url.href);
return {app,definition,url,entryPoint};
}
@ -62,18 +64,18 @@ export class Activities {
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))
if (!app || !['settings','system','homescreen','verticalhome','ringtones','sms','communications','email','calendar','gallery','video','music','camera'].includes(app.id))
throw Error('PERMISSION_DENIED');
const types = [].concat(source?.data?.type || []);
if (source?.name==='pick' && types.length>1 && this.choose) {
if (source?.name==='share' || (source?.name==='pick' && types.length>1 && this.choose)) {
const routes=[];
for (const type of types) {
for (const candidate of (source.name==='share' ? ['sms','email'] : types)) {
try {
const route=this.route({...source,data:{...source.data,type}},app);
const route=source.name==='share' ? this.route(source,app,candidate) : this.route({...source,data:{...source.data,type:candidate}},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 (routes.length>1 && this.choose) {
if (this.choices.size) throw Error('ACTIVITY_BUSY');
const id=this.uuid();
return new Promise((resolve,reject)=>{
@ -87,6 +89,7 @@ export class Activities {
});
}
if (routes.length===1) return this.open(caller,app,source,routes[0]);
if(source.name==='share')throw Error('NO_PROVIDER');
}
return this.open(caller,app,source,this.route(source,app));
}

View file

@ -36,10 +36,10 @@ export class InputEditor {
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));
// Software Enter edits the field or submits its form. Synthetic keyboard
// events enter native key-binding IPC on the standalone Wayland host.
if(e.isContentEditable || e.localName==='textarea') this.document.execCommand('insertLineBreak');
else if(e.localName==='input' && e.form) e.form.requestSubmit();
} else {
const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
if(text.length>16) throw Error('INVALID_VALUE');

View file

@ -2,7 +2,9 @@
export class Input {
constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
changed(actor, state) {
const app=this.runtime.identity(actor);
// Physical keypads edit through their actor; do not cover the small screen with an IME.
if (this.runtime.hardwareKeyboard) { if (this.focus) this.hide(); return; }
const app=this.runtime.inputIdentity ? this.runtime.inputIdentity(actor) : 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}};
@ -27,6 +29,9 @@ export class Input {
}
if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
const context=this.focus;
if (this.runtime.inputIdentity && !this.runtime.inputIdentity(context.actor)) {
this.hide(); throw Error('INPUT_CONTEXT_EXPIRED');
}
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');

View file

@ -0,0 +1,19 @@
export function nextKeypadTarget(items, current, direction) {
if (!items.length) return -1;
if (current < 0 || current >= items.length) return 0;
const vectors = {ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1]};
const vector = vectors[direction];
if (!vector) return -1;
const origin = items[current];
let best = -1, score = Infinity;
for (let index = 0; index < items.length; index++) {
if (index === current) continue;
const dx = items[index].x - origin.x, dy = items[index].y - origin.y;
const forward = dx * vector[0] + dy * vector[1];
if (forward <= 1) continue;
const sideways = Math.abs(dx * vector[1] - dy * vector[0]);
const distance = forward + sideways * 3;
if (distance < score) { best = index; score = distance; }
}
return best;
}

105
services/keypad-text.mjs Normal file
View file

@ -0,0 +1,105 @@
// Numeric keypad composition. The caller supplies field identity and selection.
const letters = {
'0': ' 0', '1': '.,?!@-_:;/1', '2': 'abcàâç2', '3': 'deféèêë3',
'4': 'ghiîï4', '5': 'jkl5', '6': 'mnoôö6', '7': 'pqrs7',
'8': 'tuvùûü8', '9': 'wxyz9', '*': '*+()[]#',
};
export class KeypadText {
mode = 'abc';
pending = null;
dictionary = new Map();
returnMode = 'abc';
setDictionary(lines) {
const words = [];
for (const line of lines.split(/\r?\n/)) {
const [word, frequency] = line.split('\t');
const key = t9KeySequence(word || '');
if (!key) continue;
words.push({word,key,frequency:Number(frequency) || 0});
}
words.sort((a,b)=>b.frequency-a.frequency || a.word.localeCompare(b.word));
const prefixes=new Map();
for (const entry of words) for(let length=1;length<=entry.key.length;length++) {
const prefix=entry.key.slice(0,length),list=prefixes.get(prefix) || [];
if(list.length<32) {list.push(entry);prefixes.set(prefix,list);}
}
this.dictionary=prefixes;
}
formatT9(word) { return this.returnMode==='ABC' ? word.toUpperCase() : word; }
get suggestion() { return this.pending?.t9 ? this.formatT9(this.pending.candidates[this.pending.index]?.word || '') : null; }
setT9(enabled, previousMode=this.mode) {
if (enabled) { this.returnMode=previousMode; this.mode='t9'; }
else this.mode=previousMode;
this.commit();
return {mode:this.mode};
}
toggleT9() {
return this.setT9(this.mode!=='t9',this.mode==='t9' ? this.returnMode : this.mode);
}
commit() { this.pending = null; }
press(key, {field, text, start, end, numeric = false, now = Date.now()}) {
if (numeric) { this.commit(); return /^[0-9*#+]$/.test(key) ? {text:key, replace:0} : null; }
if (key === '#') {
this.mode = this.mode==='t9' ? this.returnMode : {abc:'ABC', ABC:'123', '123':'abc'}[this.mode];
this.commit(); return {mode:this.mode};
}
if (this.mode === 't9') return this.pressT9(key,{field,text,start,end,now});
if (!letters[key]) { this.commit(); return null; }
if (this.mode === '123' && key !== '*') { this.commit(); return {text:key,replace:0}; }
const choices = this.mode === 'ABC' ? letters[key].toUpperCase() : letters[key];
const p = this.pending;
const cycle = p && p.field === field && p.key === key && now - p.time < 900 &&
start === end && start === p.end && text.slice(start - p.text.length, start) === p.text;
const index = cycle ? (p.index + 1) % choices.length : 0;
const value = choices[index];
this.pending = {field, key, index, text:value, time:now,
end:(cycle ? start - p.text.length : start) + value.length};
return {text:value, replace:cycle ? p.text.length : 0};
}
pressT9(key,{field,text,start,end,now}) {
const p=this.pending;
const continuing=p?.t9 && p.field===field && start===end && start===p.end &&
text.slice(p.start,start)===p.text;
if (key==='*' && continuing && p.candidates.length>1) {
p.index=(p.index+1)%p.candidates.length;
const value=this.formatT9(p.candidates[p.index].word);
const replace=p.text.length;
p.text=value;p.end=p.start+value.length;p.time=now;
return {text:value,replace};
}
if (key==='0') { this.commit(); return {text:' ',replace:0}; }
if (key==='1') { this.commit(); return {text:'.',replace:0}; }
if (!/^[2-9]$/.test(key)) { this.commit(); return null; }
const sequence=continuing ? p.sequence+key : key;
const candidates=this.dictionary.get(sequence) || [];
if (!candidates.length) {
this.commit();
return {text:letters[key][0],replace:0};
}
const base=continuing ? p.start : start;
const value=this.formatT9(candidates[0].word);
const replace=continuing ? p.text.length : 0;
this.pending={t9:true,field,sequence,candidates,index:0,text:value,
start:base,end:base+value.length,time:now};
return {text:value,replace};
}
}
const latinKeys=['abc','def','ghi','jkl','mno','pqrs','tuv','wxyz'];
const cyrillicKeys=['абвг','дежзё','ийкл','мноп','рсту','фхцч','шщъы','ьэюя'];
const greekKeys=['αβγ','δεζ','ηθι','κλμ','νξο','πρσ','τυφ','χψω'];
export function t9KeySequence(word) {
let result='';
const normalized=String(word).toLocaleLowerCase()
.replace(/[æǽ]/g,'ae').replace(/œ/g,'oe').replace(/ł/g,'l')
.replace(/đ/g,'d').replace(/ð/g,'d').replace(/þ/g,'t')
.replace(/ß/g,'ss').replace(/ς/g,'σ');
for (const original of normalized) {
const char=original.normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace('ı','i');
const groups=/[a-z]/.test(char) ? latinKeys : /[\u0400-\u04ff]/.test(char) ? cyrillicKeys : greekKeys;
const group=groups.findIndex(letters=>letters.includes(char));
if (group<0) return '';
result+=String(group+2);
}
return result;
}

View file

@ -0,0 +1,23 @@
// Use Gaia's permission panel so fullscreen hosts do not open a hidden desktop dialog.
export function requestMediaPermission(win, request, clone = value => value) {
return new Promise(resolve => {
const finish = result => {
win.removeEventListener('mozContentEvent', response);
win.removeEventListener('pagehide', cancelled);
resolve(result);
};
const response = event => {
const detail = event.detail;
if (detail?.id !== request.id || !['permission-allow', 'permission-deny'].includes(detail.type)) return;
finish({allowed:detail.type === 'permission-allow', remember:detail.remember === true});
};
const cancelled = () => finish({allowed:false, remember:false});
win.addEventListener('mozContentEvent', response);
win.addEventListener('pagehide', cancelled, {once:true});
win.dispatchEvent(new win.CustomEvent('mozChromeEvent', {detail:clone({
type:'permission-prompt', id:request.id, origin:request.origin,
manifestURL:request.manifestURL, isApp:true, isGranted:false, remember:false,
permissions:{[request.kind === 'microphone' ? 'audio-capture' : 'video-capture']:[]},
})}));
});
}

View file

@ -2,6 +2,10 @@
const MAX_ZIP = 16 * 1024 * 1024, MAX_FILES = 2048, MAX_EXPANDED = 48 * 1024 * 1024;
export function packageURL(value) {
const url = new URL(value);
if (url.username || url.password) throw Error('INVALID_PACKAGE_URL');
if (url.origin === 'https://ffapps.danielherr.software' && !url.search && !url.hash &&
/^\/app\/[^/]+\/(?:manifest\.webapp|[^/]+\.zip)$/.test(decodeURIComponent(url.pathname)) &&
!/[\\\x00-\x1f]/.test(decodeURIComponent(url.pathname))) return url.href;
if (url.origin !== 'https://vulpes-os.org' || url.pathname !== '/marketplace/index.php' ||
!/^\d{1,10}$/.test(url.searchParams.get('download') || '') || url.username || url.password)
throw Error('INVALID_PACKAGE_URL');
@ -63,8 +67,17 @@ export class Packages {
this.cancel();const controller=this.controller=new AbortController();
const timer=this.schedule(()=>controller.abort(),60000);
try {
const source=packageURL(url),response=await this.fetchPackage(source,{redirect:'error',credentials:'omit',signal:controller.signal});
const source=packageURL(url),options={redirect:'error',credentials:'omit',signal:controller.signal};
let response=await this.fetchPackage(source,options);
if(!response.ok)throw Error('DOWNLOAD_FAILED');
if(new URL(source).pathname.endsWith('/manifest.webapp')) {
const manifest=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(await bounded(response.body,128*1024)));
if(typeof manifest.package_path!=='string')throw Error('MISSING_PACKAGE');
const archive=packageURL(new URL(manifest.package_path,source).href);
if(new URL(archive).origin!==new URL(source).origin || !new URL(archive).pathname.endsWith('.zip'))throw Error('INVALID_PACKAGE_URL');
response=await this.fetchPackage(archive,options);
if(!response.ok)throw Error('DOWNLOAD_FAILED');
}
const item=await unpackPackage(await bounded(response.body,MAX_ZIP),source);
if(controller.signal.aborted)throw Error('DOWNLOAD_CANCELLED');
this.pending.clear();const token=this.uuid();this.pending.set(token,{item,expires:Date.now()+300000});

View file

@ -13,6 +13,7 @@ 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),
radioPower: ['system', 'settings'].includes(app.id),
mobileSettings: app.id === 'settings',
deviceSettings: app.id === 'settings',
bluetooth: ['settings', 'system', 'bluetooth'].includes(app.id),
@ -34,15 +35,18 @@ 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 === 'radioPower' && (!Number.isInteger(params.slot) || ![0,1].includes(params.slot) || typeof params.enabled !== 'boolean')) throw Error('INVALID_VALUE');
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) ||
(!['start', 'frame', 'stop', 'focus', 'zoom', 'picture', 'flash'].includes(params.action) ||
(params.action === 'start' && !['back', 'front'].includes(params.camera))))
throw Error('INVALID_VALUE');
if (operation === 'cameraPreview') {
if (params.action === 'flash' && !['off','auto','on','torch'].includes(params.mode))
throw Error('INVALID_VALUE');
if (params.action === 'zoom' && (!Number.isFinite(params.zoom) || params.zoom<1 || params.zoom>100))
throw Error('INVALID_VALUE');
if (params.action === 'focus' && params.area != null) {

29
services/radio-state.mjs Normal file
View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: MPL-2.0
// Public radio state excludes subscriber identifiers, cell location and raw parcels.
export function radioState(value) {
if (!Array.isArray(value)) return [];
return value.slice(0, 4).map((slot, index) => {
if (!slot || slot.slot !== index || slot.available !== true)
return {slot:index, available:false, radioState:'unknown'};
const state = {
slot:index, available:true,
radioState:['enabled','disabled','unavailable'].includes(slot.radioState) ? slot.radioState : 'unknown',
signal:Number.isFinite(slot.signal) && slot.signal>=0 && slot.signal<=100 ? slot.signal : null,
operator:{longName:String(slot.operator?.longName || '').slice(0,100),
shortName:String(slot.operator?.shortName || '').slice(0,100)},
};
if (typeof slot.sim?.present === 'boolean') state.sim = {
present:slot.sim.present,
cardState:['absent','ready','pinRequired','pukRequired','networkLocked'].includes(slot.sim.cardState)
? slot.sim.cardState : 'unknown',
};
for (const kind of ['voice','data']) {
const info=slot[kind] || {};
state[kind]={state:['notSearching','registered','searching','denied'].includes(info.state) ? info.state : 'unknown',
connected:info.connected === true, roaming:info.roaming === true,
emergencyCallsOnly:info.emergencyCallsOnly === true,
type:['gprs','edge','umts','is95a','is95b','1xrtt','evdo0','evdoa','hsdpa','hsupa','hspa','evdob','ehrpd','lte','hspa+','gsm'].includes(info.type) ? info.type : null};
}
return state;
});
}

View file

@ -1,6 +1,25 @@
// No Gecko, XPCOM, DOM or filesystem imports in the service contract.
export const API_VERSION = 1;
export function migratePrivacyDefaults(values) {
if (values['vulpes.privacy-default.v1']) return false;
values['privacy.trackingprotection.enabled'] = true;
values['privacy.trackingprotection.shown'] = true;
values['privacy.donottrackheader.enabled'] = true;
values['privacy.donottrackheader.value'] = 1;
values['vulpes.privacy-default.v1'] = true;
return true;
}
export function migrateSearchDefaults(values) {
if (values['vulpes.search-default.v1']) return false;
// A missing/false provider means the old bundled default, not a user choice.
if (!values['search.provider']) values['search.provider'] = 'ddg';
delete values['search.cache'];
values['vulpes.search-default.v1'] = true;
return true;
}
export class ServiceError extends Error {
constructor(code) {
super(code);

View file

@ -0,0 +1,9 @@
// Gaia's transparent edge gesture zones are not modal overlays.
export function frameUnobscured(document, frame, rect) {
if (document.hidden || rect.width <= 0 || rect.height <= 0) return false;
const system = document.location.hostname === 'system.localhost';
return [[.5, .5], [.05, .05], [.95, .05], [.05, .95], [.95, .95]].every(([x, y]) => {
const hit = document.elementFromPoint(rect.left + rect.width * x, rect.top + rect.height * y);
return hit === frame || (system && !!hit?.matches('#left-panel.gesture-panel, #right-panel.gesture-panel'));
});
}