VulpesOS/host/gaia-services.js
OverSu 1311bed5e9 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.
2026-10-13 00:19:45 +02:00

482 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Content-side legacy interfaces; every operation is authorized by the host.
(() => {
const { call, request, handlers } = window.VulpesCompat;
document.documentElement.dataset.vulpesApp = location.hostname.split('.')[0];
if(window.VulpesPlatform==='tundra' && location.hostname==='system.localhost') {
Object.defineProperty(navigator,'vibrate',{value:pattern=>{
pattern=Array.isArray(pattern)?pattern:[pattern];
if(pattern.length>32 || pattern.some(n=>!Number.isInteger(n) || n<0 || n>10000) || pattern.reduce((a,b)=>a+b,0)>10000) return false;
call('hardware.vibrate',{pattern}).catch(error=>console.warn('Vibration:',error.message));
return true;
}});
} else if (!navigator.vibrate) Object.defineProperty(navigator, 'vibrate', { value: () => false });
if (window.Notification && !Notification.get)
Notification.get = async () => {
const registrations = await navigator.serviceWorker.getRegistrations();
return (await Promise.all(registrations.map((r) => r.getNotifications()))).flat();
};
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'];
for (const id of ids) {
const e = document.getElementById('quick-settings-' + id);
if (!e) continue;
e.setAttribute('aria-disabled', 'true');
e.title = 'Connexion gérée par le système hôte';
e.style.cssText += ';display:block;color:#aaa;opacity:.6';
e.addEventListener(
'click',
(event) => {
event.preventDefault();
event.stopImmediatePropagation();
unavailable(
'Sur ce bureau, les connexions réseau sont gérées par Linux. Aucun modem téléphonique n’est connecté.',
);
},
true,
);
}
}
if (location.hostname === 'settings.localhost') {
document.addEventListener(
'click',
(event) => {
const link = event.target.closest(
'a[href="#fxa"],a[href="#fxa-about"],a[href="#fxa-signin"],a[href="#fxa-sync"]',
);
if (!link) return;
event.preventDefault();
event.stopImmediatePropagation();
unavailable(
'Les comptes Firefox et la synchronisation distante ne sont pas portés dans cette version bureau.',
);
},
true,
);
}
if (location.hostname === 'communications.localhost') {
document.getElementById('keypad-callbar-call-action')?.addEventListener(
'click',
(event) => {
if (navigator.mozTelephony || window.VulpesPlatform) return;
event.preventDefault();
event.stopImmediatePropagation();
unavailable(
'Appels indisponibles : aucun modem téléphonique n’est connecté à cette version bureau.',
);
},
true,
);
}
});
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: () =>
request(
call('alarms.request', { operation: 'getAll' }).then((list) =>
list.map((r) => ({ ...r, date: new Date(r.date) })),
),
),
add: (date, respectTimezone, data) =>
request(
call('alarms.request', {
operation: 'add',
date: +date,
respectTimezone,
data: data ?? null,
local: [
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds(),
],
}),
),
remove: (id) => {
call('alarms.request', { operation: 'remove', id }).catch(console.error);
},
},
});
addEventListener('vulpes-service-event', (event) => {
if (event.detail.type === 'alarm') {
const data = event.detail.data;
handlers.get('alarm')?.({ ...data, date: new Date(data.date) });
}
});
window.VulpesCompat.openAttention = (url) => {
const target = new URL(url, location.href);
if (
target.origin !== location.origin ||
target.pathname !== '/onring.html' ||
location.hostname !== 'clock.localhost'
)
throw new DOMException('Unsupported attention view', 'NotSupportedError');
const frame = document.createElement('iframe');
frame.dataset.vulpesAttention = 'true';
frame.src = target.href;
frame.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;z-index:10000';
document.body.append(frame);
call('apps.launch', { manifestURL: location.origin + '/manifest.webapp' }).catch(console.error);
return frame.contentWindow;
};
if (
location.hostname === 'clock.localhost' &&
location.pathname === '/onring.html' &&
window.frameElement?.dataset.vulpesAttention === 'true'
) {
window.opener = parent;
window.close = () => {
dispatchEvent(new Event('beforeunload'));
window.frameElement.remove();
};
}
const ports = new Map();
function portFor({ id }) {
if (ports.has(id)) return ports.get(id);
const port = new EventTarget();
let listener = null,
started = false,
queue = [];
const deliver = (data) => port.dispatchEvent(new MessageEvent('message', { data }));
port.start = () => {
started = true;
for (const data of queue) deliver(data);
queue = [];
};
Object.defineProperty(port, 'onmessage', {
get: () => listener,
set(fn) {
if (listener) port.removeEventListener('message', listener);
listener = fn;
if (fn) port.addEventListener('message', fn);
port.start();
},
});
port.postMessage = (data) =>
call('iac.send', { id, data }).catch((error) =>
port.dispatchEvent(new MessageEvent('messageerror', { data: error.message })),
);
port.close = () => {
ports.delete(id);
call('iac.close', { id }).catch(console.error);
};
port.receive = (data) => (started ? deliver(data) : queue.push(data));
ports.set(id, port);
return port;
}
window.VulpesCompat.connect = (keyword) =>
call('iac.connect', { keyword }).then((list) => list.map(portFor));
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type === 'connection')
handlers.get('connection')?.({ keyword: data.keyword, port: portFor(data) });
if (type === 'portmessage') portFor(data).receive(data.data);
if (type === 'portclose') {
const p = ports.get(data.id);
ports.delete(data.id);
p?.dispatchEvent(new Event('close'));
}
});
function cursor(promise, convert = (value) => value) {
let values,
index = 0,
ready = false;
const c = new EventTarget();
c.result = null;
c.done = false;
function next() {
if (!ready) return;
setTimeout(async () => {
try {
c.result = index < values.length ? await convert(values[index++]) : null;
c.done = c.result === null;
c.onsuccess?.({ target: c });
c.dispatchEvent(new Event('success'));
} catch (error) {
c.error = error;
c.onerror?.({ target: c });
c.dispatchEvent(new Event('error'));
}
}, 0);
}
c.continue = next;
promise.then(
(list) => {
values = list;
ready = true;
next();
},
(error) => {
c.error = error;
c.onerror?.({ target: c });
c.dispatchEvent(new Event('error'));
},
);
return c;
}
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'])
messages[operation] = (...args) =>
cursor(call('messages.request', { operation, args }), (value) => ({
...value,
timestamp: new Date(value.timestamp),
}));
for (const operation of [
'getMessage',
'send',
'sendMMS',
'retrieveMMS',
'delete',
'markMessageRead',
'getSmscAddress',
'setSmscAddress',
'getSegmentInfoForText',
])
messages[operation] = (...args) => request(call('messages.request', { operation, args }));
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));
const submit = (operation,args) => {
let id, settle;
const early = new Map();
const terminal = new Promise((resolve,reject)=>{settle=message=>{
if(message.delivery==='sent') resolve(dated(message));
else reject(new DOMException('SMS transmission failed','UnknownError'));
};});
const listener=({detail})=>{
if(!['sms-sent','sms-failed'].includes(detail.type)) return;
const message=detail.data;
if(id===message.id) settle(message);
else if(id===undefined && early.size<100) early.set(message.id,message);
};
addEventListener('vulpes-service-event',listener);
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));
early.clear();
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;
const message=dated(detail.data);
const event=Object.assign(new Event(type),{message});
messages.dispatchEvent(event);messages['on'+type]?.(event);
});
Object.defineProperty(navigator, 'mozMobileMessage', { value: messages });
}
if (
['settings', 'gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
) {
const stores = new Map();
function storage(type) {
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 = {}) => {
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(
run('get', { name }).then((f) => {
const file = new File([f.blob], f.name, { type: f.type, lastModified: f.lastModified });
Object.defineProperty(file, 'lastModifiedDate', { value: new Date(f.lastModified) });
return file;
}),
);
s.addNamed = (blob, name) => request(run('add', { blob, name }));
s.add = (blob) =>
s.addNamed(
blob,
blob.name ||
crypto.randomUUID() + { pictures: '.jpg', music: '.mp3', videos: '.mp4' }[type],
);
s.delete = (name) => request(run('delete', { name }));
s.enumerate = (name = '', options = {}) => {
if (typeof name === 'object') {
options = name;
name = '';
}
return cursor(run('enumerate', { name, since: options.since ? +options.since : 0 }), (f) =>
s.get(f.name),
);
};
stores.set(type, s);
return s;
}
navigator.getDeviceStorage = storage;
navigator.getDeviceStorages = (type) => {
const s = storage(type);
return s ? [s] : [];
};
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type !== 'mediachange') return;
const s = stores.get(data.type);
if (!s) return;
const change = new Event('change');
Object.assign(change, { path: data.path, reason: data.reason });
s.dispatchEvent(change);
s.onchange?.(change);
});
addEventListener('DOMContentLoaded', () => {
const type = { gallery: 'pictures', music: 'music', video: 'videos' }[
location.hostname.split('.')[0]
];
if (!type || !['/', '/index.html'].includes(location.pathname) || window.VulpesPlatform)
return;
const label = document.createElement('label');
label.className = 'vulpes-import';
label.textContent = 'Importer';
label.title = 'Copier des fichiers dans le profil de cet émulateur';
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = { pictures: 'image/*', music: 'audio/*', videos: 'video/*' }[type];
input.hidden = true;
label.append(input);
input.addEventListener('change', async () => {
try {
for (const file of input.files) await storage(type).addNamed(file, file.name);
label.title = 'Import terminé';
} catch (error) {
unavailable('Import impossible : ' + error.message);
} finally {
input.value = '';
}
});
document.body.append(label);
});
}
class Contact {
constructor(data = {}) {
this.init(data);
}
init(data) {
Object.assign(this, data);
for (const field of [
'name',
'givenName',
'familyName',
'additionalName',
'honorificPrefix',
'honorificSuffix',
'nickname',
'email',
'tel',
'adr',
'org',
'jobTitle',
'note',
'url',
'category',
'photo',
])
if (!this[field]) this[field] = [];
}
}
window.mozContact = Contact;
const contacts = new EventTarget();
function convert(value) {
const c = new Contact(value);
for (const key of ['published', 'updated', 'bday', 'anniversary'])
if (c[key]) c[key] = new Date(c[key]);
return c;
}
contacts.find = (options) =>
request(
call('contacts.request', { operation: 'find', options }).then((list) => list.map(convert)),
);
contacts.getAll = (options) =>
cursor(call('contacts.request', { operation: 'all', options }), convert);
contacts.save = (contact) =>
request(
call('contacts.request', { operation: 'save', contact }).then((saved) => {
Object.assign(contact, convert(saved));
return null;
}),
);
contacts.remove = (contact) =>
request(
call('contacts.request', {
operation: 'remove',
id: typeof contact === 'string' ? contact : contact.id,
}),
);
contacts.clear = () => request(call('contacts.request', { operation: 'clear' }));
contacts.getCount = () => request(call('contacts.request', { operation: 'count' }));
contacts.getRevision = () => request(call('contacts.request', { operation: 'revision' }));
Object.defineProperty(navigator, 'mozContacts', { value: contacts });
addEventListener('vulpes-service-event', (event) => {
if (event.detail.type !== 'contactchange') return;
const change = new Event('contactchange');
Object.assign(change, event.detail.data);
contacts.dispatchEvent(change);
contacts.oncontactchange?.(change);
});
})();