VulpesOS/host/Platform.sys.mjs
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

522 lines
22 KiB
JavaScript

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';
import {Notifications, notificationLaunch} from 'resource://vulpes/services/notifications.mjs';
import {CallJournal} from 'resource://vulpes/services/call-journal.mjs';
import {NativeCamera} from 'resource://vulpes/host/NativeCamera.sys.mjs';
import {setTimeout} from 'resource://gre/modules/Timer.sys.mjs';
Cu.importGlobalProperties(['File']);
// Fixed desktop commands only: app requests cannot supply executable paths or arguments.
const PANELS = {
root: ['kcm_about-distro', ''],
wifi: ['kcm_networkmanagement', 'wifi'],
bluetooth: ['kcm_bluetooth', 'bluetooth'],
data: ['kcm_networkmanagement', 'network'],
'airplane-mode': ['kcm_networkmanagement', 'network'],
geolocation: ['kcm_regionandlang', 'privacy'],
sound: ['kcm_pulseaudio', 'sound'],
dateTime: ['kcm_clock', 'datetime'],
screenLock: ['kcm_screenlocker', 'privacy'],
battery: ['kcm_powerdevilprofilesconfig', 'power'],
storage: ['kcm_about-distro', 'info-overview'],
apps: ['kcm_componentchooser', 'applications'],
notifications: ['kcm_notifications', 'notifications'],
};
function file(path) {
const f = Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);
f.initWithPath(path);
return f;
}
function installed(path) {
return file(path).exists();
}
export class Platform {
constructor(runtime) {
this.runtime = runtime;
this.notifications = new Map();
this.nextId = 1;
this.cameraWindow = null;
this.nativeCamera = new NativeCamera();
this.picking = false;
}
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 (_) {}
this.gaiaNotifications = new Notifications(notifications);
this.notificationQueue = Promise.resolve();
this.journalPath = PathUtils.join(PathUtils.profileDir, 'vulpes-call-journal.json');
let state = {};
try { state = await IOUtils.readJSON(this.journalPath); } catch (_) {}
this.journal = new CallJournal(state);
// oFono paths are reusable across boots. Do not merge an old interrupted
// session with a new call carrying the same object path.
this.journal.update([]);
if (Services.env.get('VULPES_TUNDRA_RADIO') === '1') {
const poll = async () => {
try { await this.radio('calls'); } catch (_) {}
setTimeout(poll, 500);
};
poll();
const inbox = async()=>{
try { await this.runtime.messages.refreshOutgoing(); }
catch(error) {console.error('SMS outgoing:',error.message);}
try {
const rows=await tundraRadio('incomingMessages');
const app=this.runtime.apps.find(a=>a.id==='sms');
for (const row of rows) {
const message=await this.runtime.messages.receive(row);
await this.gaiaNotification(app,{operation:'show',title:message.sender,
body:message.body,tag:'sms-'+message.id,data:{threadId:message.threadId}});
this.runtime.broadcast('sms-received',message,'sms');
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();
}
}
radio(operation, params = {}) {
const work = this.radioQueue.then(async () => {
if (operation === 'callHistory') return this.journal.completed.slice();
const before = JSON.stringify(this.journal.snapshot());
if (operation === 'ackCallHistory') this.journal.acknowledge(params.ids);
else {
const result = await tundraRadio(operation, params);
if (operation === 'dial') this.journal.start({path:result.path,number:params.number,state:'dialing'}, Date.now(), 'outgoing');
if (operation === 'calls') {
const previous = new Set(this.journal.completed.map(c=>c.id));
this.journal.update(result); this.lastCalls = result; this.lastCallsAt = Date.now();
for (const call of this.journal.completed) if (!previous.has(call.id) && call.direction==='incoming' && call.connectedAt===null) {
const app = this.runtime.apps.find(a=>a.id==='communications');
if (app) await this.gaiaNotification(app,{operation:'show',
title:String(this.runtime.values['language.current']).startsWith('fr')?'Appel manqué':'Missed call',
body:call.number,tag:'missed-call-'+call.id,data:{callId:call.id}});
}
}
if (JSON.stringify(this.journal.snapshot()) !== before) await IOUtils.writeJSON(this.journalPath, this.journal.snapshot(), {tmpPath:this.journalPath+'.tmp', permissions:0o600});
return result;
}
await IOUtils.writeJSON(this.journalPath, this.journal.snapshot(), {tmpPath:this.journalPath+'.tmp', permissions:0o600});
return null;
});
this.radioQueue = work.catch(() => {});
return work;
}
window(actor) {
return actor.browsingContext.top.embedderElement.ownerDocument.defaultView;
}
async mediaPermission(browser, request) {
const find = (context) => {
if (context.currentWindowGlobal?.outerWindowId === request.windowID)
return context.currentWindowGlobal;
for (const child of context.children) {
const found = find(child);
if (found) return found;
}
return null;
};
const global = find(browser.browsingContext);
if (!global) return;
const actor = global.getActor('WebRTC');
const deny = () =>
actor.sendAsyncMessage('webrtc:Deny', { callID: request.callID, windowID: request.windowID });
const app = this.runtime.apps.find((a) => a.id === 'camera');
if (
global.documentPrincipal.origin !== app?.origin ||
!request.secure ||
request.sharingScreen ||
request.sharingAudio ||
!(request.videoInputDevices?.length || request.audioInputDevices?.length)
) {
deny();
return;
}
const principal = global.documentPrincipal;
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 (!(await actor.checkOSPermission(!!video, !!audio, false))) { deny(); return; }
const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal;
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,
});
}
async request(actor, app, operation, params) {
authorizePlatform(app, operation, params);
if (operation === 'notificationEvent') return this.gaiaNotificationEvent(params);
if (operation === 'callScreenClosed') {
this.runtime.broadcast('callscreen-closed', {}, 'system');
return null;
}
if (Services.env.get('VULPES_TUNDRA') === '1') {
if (operation === 'capabilities') return tundraCapabilities(this.runtime.release);
if (operation === 'cameraPreview') return this.nativeCamera.request(actor, params);
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);
if (operation === 'calls') return Date.now() - this.lastCallsAt < 1500 ? this.lastCalls : this.radio('calls');
if (operation === 'answer') return tundraRadio('answer', params);
if (operation === 'callAudio') return tundraCallAudio(params);
if (operation === 'sms') return tundraRadio('sms', params);
if (operation === 'notification') return this.gaiaNotification(app,params);
if (['settings', 'capture', 'alarms', 'notification'].includes(operation))
throw Error('NOT_SUPPORTED');
}
if (operation === 'capabilities')
return {
platform: 'desktop',
platformName: Services.appinfo.OS,
adapter: { version: this.runtime.release.adapters?.desktop, device: 'desktop' },
version: this.runtime.release.version,
gaia: this.runtime.release.gaia,
gecko: Services.appinfo.platformVersion,
camera: true,
media: true,
contacts: true,
notifications: true,
dial: false,
sms: false,
alarms: installed('/usr/bin/gnome-clocks'),
};
if (operation === 'systemReady') return null;
if (operation === 'settings') return this.settings(params.panel);
if (operation === 'snapshot')
return {
'deviceinfo.platform_version': Services.appinfo.platformVersion,
'deviceinfo.software': 'Vulpes OS ' + this.runtime.release.version,
};
if (operation === 'notification') return this.notification(actor, app, params);
if (operation === 'pick') return this.pick(actor, app, params.type);
if (operation === 'capture') return this.capture(actor, app);
if (operation === 'contact') return this.contact(actor, app);
if (operation === 'alarms') return this.execute('/usr/bin/gnome-clocks', []);
throw Error('NOT_SUPPORTED');
}
execute(path, args) {
if (!installed(path)) throw Error('PANEL_UNAVAILABLE');
const process = Cc['@mozilla.org/process/util;1'].createInstance(Ci.nsIProcess);
process.init(file(path));
process.runwAsync(args, args.length);
return null;
}
settings(panel) {
if (!Object.hasOwn(PANELS, panel)) throw Error('PANEL_UNAVAILABLE');
// No generic Linux location switch exists; do not open an unrelated control.
if (panel === 'geolocation') throw Error('NOT_SUPPORTED');
if (installed('/usr/bin/systemsettings'))
return this.execute('/usr/bin/systemsettings', panel === 'root' ? [] : [PANELS[panel][0]]);
if (installed('/usr/bin/gnome-control-center'))
return this.execute(
'/usr/bin/gnome-control-center',
panel === 'root' ? [] : [PANELS[panel][1]],
);
throw Error('PANEL_UNAVAILABLE');
}
async select(actor, kind, multiple = false) {
if (this.picking) throw Error('PICKER_BUSY');
this.picking = true;
try {
const picker = Cc['@mozilla.org/filepicker;1'].createInstance(Ci.nsIFilePicker);
picker.init(
this.window(actor).browsingContext,
'Vulpes — Import',
multiple ? Ci.nsIFilePicker.modeOpenMultiple : Ci.nsIFilePicker.modeOpen,
);
const filter = {
pictures: Ci.nsIFilePicker.filterImages,
music: Ci.nsIFilePicker.filterAudio,
videos: Ci.nsIFilePicker.filterVideo,
}[kind];
if (filter) picker.appendFilters(filter);
else picker.appendFilter('vCard', '*.vcf');
if ((await new Promise((resolve) => picker.open(resolve))) !== Ci.nsIFilePicker.returnOK)
throw Error('AbortError');
return multiple
? Array.from(picker.files, (entry) => entry.QueryInterface(Ci.nsIFile))
: [picker.file];
} finally {
this.picking = false;
}
}
async pick(actor, app, type) {
const files = await this.select(actor, type, true);
const names = [];
for (const item of files) {
if (item.fileSize > 32 * 1024 * 1024) throw Error('MEDIA_TOO_LARGE_32_MIB');
const blob = await File.createFromFileName(item.path);
const name =
Services.uuid.generateUUID().toString().replace(/[{}]/g, '') + '/' + item.leafName;
names.push(await this.runtime.media.request(app, { operation: 'add', type, name, blob }));
}
return names;
}
async contact(actor, app) {
const [item] = await this.select(actor, 'contact');
if (item.fileSize > 1024 * 1024) throw Error('QuotaExceededError');
const raw = await IOUtils.readUTF8(item.path);
const lines = raw.replace(/\r?\n[ \t]/g, '').split(/\r?\n/);
const decode = (s) => s.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1');
const value = { name: [], tel: [], email: [] };
let inside = false;
for (const line of lines) {
if (line.toUpperCase() === 'BEGIN:VCARD') {
inside = true;
continue;
}
if (line.toUpperCase() === 'END:VCARD') break;
if (!inside) continue;
const i = line.indexOf(':');
if (i < 0) continue;
const key = line.slice(0, i).split(';')[0].toUpperCase(),
v = decode(line.slice(i + 1));
if (key === 'FN') value.name = [v];
if (key === 'TEL') value.tel.push({ value: v.replace(/^tel:/, ''), type: ['other'] });
if (key === 'EMAIL') value.email.push({ value: v, type: ['other'] });
}
if (!value.name.length && !value.tel.length && !value.email.length)
throw Error('InvalidModificationError');
return this.runtime.contacts.request(app, { operation: 'save', contact: value });
}
async capture(actor, app) {
if (!app.manifest.permissions?.['device-storage:pictures']?.access?.includes('write'))
throw Error('PERMISSION_DENIED');
if (this.cameraWindow && !this.cameraWindow.closed) {
this.cameraWindow.focus();
throw Error('PICKER_BUSY');
}
return new Promise((resolve, reject) => {
const options = {
finish: async (blob) => {
try {
const name = 'Photo-' + Date.now() + '.jpg';
const path = await this.runtime.media.request(app, {
type: 'pictures',
operation: 'add',
name,
blob,
});
resolve([path]);
} catch (e) {
reject(e);
throw e;
}
},
cancel: () => reject(Error('AbortError')),
};
this.cameraWindow = this.window(actor).openDialog(
'chrome://vulpes/content/camera.xhtml',
'vulpes-camera',
'chrome,dialog=no,resizable,width=660,height=600',
options,
);
});
}
saveNotifications() {
const rows = this.gaiaNotifications.list();
const work = this.notificationQueue.then(()=>IOUtils.writeJSON(this.notificationPath,rows,
{tmpPath:this.notificationPath+'.tmp',permissions:0o600}));
this.notificationQueue = work.catch(()=>{});
return work;
}
async gaiaNotification(app, p) {
const principal = Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI(app.origin),{});
const denied = Services.perms.testExactPermissionFromPrincipal(principal,'desktop-notification') === Ci.nsIPermissionManager.DENY_ACTION;
// Bundled certified Gaia apps retain their install-time manifest permission.
const permission = !denied && app.manifest.type === 'certified' ? 'granted' : 'denied';
if (['query','permission'].includes(p.operation)) return permission;
if (p.operation === 'list') return this.gaiaNotifications.list(app.id);
if (p.operation === 'close') {
const row = this.gaiaNotifications.remove(p.id,app.id);
await this.saveNotifications();
this.runtime.broadcast('gaia-notification',{type:'desktop-notification-close',id:row.id},'system');
return null;
}
if (p.operation !== 'show') throw Error('METHOD_NOT_SUPPORTED');
if (permission !== 'granted') throw Error('NOTIFICATIONS_DENIED');
const row = this.gaiaNotifications.show(app,p);
await this.saveNotifications();
this.runtime.broadcast('gaia-notification',row,'system');
return row.id;
}
async gaiaNotificationEvent(p) {
if (p.type === 'resend') return this.gaiaNotifications.list();
if (!['desktop-notification-click','desktop-notification-close','desktop-notification-show'].includes(p.type)) throw Error('INVALID_VALUE');
const row = this.gaiaNotifications.rows.get(String(p.id));
if (!row) return null;
if (p.type === 'desktop-notification-show') return null;
if (p.type === 'desktop-notification-click') {
const app = this.runtime.apps.find(app=>app.id===row.owner);
if (app) {
this.runtime.broadcast('launch',{...notificationLaunch(app),timestamp:Date.now()},'system');
this.runtime.broadcast('notification-click',row,app.id);
}
} else this.runtime.broadcast('notification-close',row,row.owner);
this.gaiaNotifications.remove(row.id);
await this.saveNotifications();
this.runtime.broadcast('gaia-notification',{type:'desktop-notification-close',id:row.id},'system');
return null;
}
notification(actor, app, p) {
const principal = Services.scriptSecurityManager.createContentPrincipal(
Services.io.newURI(app.origin),
{},
);
const permission = () => {
const value = Services.perms.testExactPermissionFromPrincipal(
principal,
'desktop-notification',
);
return value === Ci.nsIPermissionManager.ALLOW_ACTION
? 'granted'
: value === Ci.nsIPermissionManager.DENY_ACTION
? 'denied'
: 'default';
};
if (p.operation === 'query') return permission();
if (p.operation === 'permission') {
if (permission() === 'granted') return 'granted';
const allowed = Services.prompt.confirm(
this.window(actor),
'Vulpes OS',
`Autoriser les notifications de ${app.manifest.name || app.id} ? / Allow notifications?`,
);
Services.perms.addFromPrincipal(
principal,
'desktop-notification',
allowed ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION,
);
return allowed ? 'granted' : 'denied';
}
if (p.operation === 'list')
return [...this.notifications.values()].filter((n) => n.owner === app.id);
const alerts = Cc['@mozilla.org/alerts-service;1'].getService(Ci.nsIAlertsService);
if (p.operation === 'close') {
const n = this.notifications.get(p.id);
if (!n || n.owner !== app.id) throw Error('PERMISSION_DENIED');
alerts.closeAlert('vulpes-' + n.id);
this.notifications.delete(n.id);
return null;
}
if (p.operation !== 'show') throw Error('METHOD_NOT_SUPPORTED');
if (permission() !== 'granted') throw Error('NOTIFICATIONS_DENIED');
const old =
p.tag && [...this.notifications.values()].find((n) => n.owner === app.id && n.tag === p.tag);
const id = old?.id || this.nextId++;
const data = { ...p, owner: app.id, id };
const alert = Cc['@mozilla.org/alert-notification;1'].createInstance(Ci.nsIAlertNotification);
alert.init(
'vulpes-' + id,
'',
p.title || 'Vulpes OS',
p.body || '',
true,
String(id),
'auto',
'',
'',
principal,
false,
false,
false,
[],
);
const hostWindow = this.window(actor);
const observer = {
observe: (_, topic) => {
if (topic === 'alertclickcallback') {
if (hostWindow.closed) return;
hostWindow.focus();
this.runtime.broadcast(
'launch',
{
manifestURL: app.manifestURL,
origin: app.origin,
url: app.origin + (app.manifest.launch_path || '/index.html'),
timestamp: Date.now(),
},
'system',
);
this.runtime.broadcast('notification-click', data, app.id);
}
if (topic === 'alertfinished' && this.notifications.get(id) === data)
this.notifications.delete(id);
},
};
this.notifications.set(id, data);
try {
alerts.showAlert(alert, observer);
} catch (error) {
this.notifications.delete(id);
throw error;
}
return id;
}
}