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.
412 lines
16 KiB
JavaScript
412 lines
16 KiB
JavaScript
// Shared Gaia integration: the host adapter supplies hardware capabilities.
|
||
(() => {
|
||
const { call } = VulpesCompat;
|
||
const app = location.hostname.split('.')[0];
|
||
const android = window.VulpesPlatform === 'android';
|
||
const host = android ? 'Android' : 'Linux';
|
||
const capabilities = call('platform.capabilities', {});
|
||
const labels = new Map();
|
||
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
|
||
function text(node, french, english) {
|
||
labels.set(node, [french, english]);
|
||
node.textContent = fr() ? french : english;
|
||
return node;
|
||
}
|
||
function translate() {
|
||
for (const [node, pair] of labels) node.textContent = pair[fr() ? 0 : 1];
|
||
}
|
||
new MutationObserver(translate).observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['lang'],
|
||
});
|
||
addEventListener('localized', translate);
|
||
function failure(error) {
|
||
if (error.name === 'AbortError' || error.message === 'AbortError') return;
|
||
const messages = {
|
||
NOT_SUPPORTED: [
|
||
'Cette fonction n’est pas disponible sur cet appareil.',
|
||
'This feature is not available on this device.',
|
||
],
|
||
PANEL_UNAVAILABLE: [
|
||
'Ce panneau n’est pas installé sur ce bureau.',
|
||
'This settings panel is not installed on this desktop.',
|
||
],
|
||
NOTIFICATIONS_DENIED: [
|
||
'Autorisez les notifications de Vulpes dans les paramètres du système.',
|
||
'Allow Vulpes notifications in system settings.',
|
||
],
|
||
MEDIA_TOO_LARGE_32_MIB: [
|
||
'Choisissez un fichier de moins de 32 Mio.',
|
||
'Choose a file smaller than 32 MiB.',
|
||
],
|
||
PICKER_BUSY: ['Terminez la sélection déjà ouverte.', 'Finish the current selection first.'],
|
||
WIFI_UNAVAILABLE: [
|
||
'Le service Wi-Fi est indisponible. Réessayez dans quelques instants.',
|
||
'Wi-Fi is unavailable. Try again shortly.',
|
||
],
|
||
WIFI_NETWORK_GONE: [
|
||
'Ce réseau n’est plus visible. Actualisez la liste.',
|
||
'This network is no longer visible. Refresh the list.',
|
||
],
|
||
WIFI_UNSUPPORTED_SECURITY: [
|
||
'Ce mode de sécurité Wi-Fi n’est pas encore pris en charge.',
|
||
'This Wi-Fi security mode is not supported yet.',
|
||
],
|
||
WIFI_INVALID_PASSWORD: ['Vérifiez le mot de passe Wi-Fi.', 'Check the Wi-Fi password.'],
|
||
WIFI_CONNECT_FAILED: [
|
||
'Connexion impossible. Vérifiez le mot de passe et la portée du réseau.',
|
||
'Connection failed. Check the password and network range.',
|
||
],
|
||
WIFI_DISCONNECT_FAILED: ['La déconnexion a échoué.', 'Disconnection failed.'],
|
||
RADIO_UNAVAILABLE: [
|
||
'Le service téléphonique est indisponible. Réessayez dans quelques instants.',
|
||
'The phone service is unavailable. Try again shortly.',
|
||
],
|
||
RADIO_NOT_REGISTERED: ['Le téléphone n’est pas connecté au réseau mobile.',
|
||
'The phone is not registered on a mobile network.'],
|
||
RADIO_NOT_READY: ['Le modem n’est pas encore prêt. Réessayez dans quelques instants.',
|
||
'The modem is not ready yet. Try again shortly.'],
|
||
RADIO_BUSY: ['Une opération téléphonique est déjà en cours.',
|
||
'A phone operation is already in progress.'],
|
||
RADIO_REQUEST_FAILED: ['Le modem a refusé la demande. Vérifiez la SIM et le réseau mobile.',
|
||
'The modem rejected the request. Check the SIM and mobile network.'],
|
||
INVALID_NUMBER: ['Numéro invalide.', 'Invalid phone number.'],
|
||
INVALID_SMS: ['SMS invalide ou trop long.', 'Invalid or oversized SMS.'],
|
||
MULTIPLE_RECIPIENTS_UNSUPPORTED: [
|
||
'Un seul destinataire est pris en charge dans cette Preview.',
|
||
'This Preview supports one recipient at a time.',
|
||
],
|
||
};
|
||
const message = messages[error.message]?.[fr() ? 0 : 1] ||
|
||
(fr() ? 'Action impossible : ' : 'Unable to complete: ') + error.message;
|
||
window.VulpesGaiaUI.notice(message);
|
||
}
|
||
|
||
const run = async (operation, params = {}) => {
|
||
try { return await call('platform.' + operation, params); }
|
||
catch (error) { failure(error); }
|
||
};
|
||
if (app === 'communications') {
|
||
// All Gaia call entry points use the same authorized host operation.
|
||
window.VulpesPlatformDial = async number => {
|
||
try {
|
||
return navigator.mozTelephony ? await navigator.mozTelephony.dial(number) :
|
||
await call('platform.dial', {number});
|
||
} catch (error) {
|
||
failure(error);
|
||
throw error;
|
||
}
|
||
};
|
||
}
|
||
function button(id, french, english, action) {
|
||
const b = text(document.createElement('button'), french, english);
|
||
b.id = id;
|
||
b.className = 'vulpes-platform-button';
|
||
b.onclick = async () => {
|
||
b.disabled = true;
|
||
try {
|
||
await action();
|
||
} catch (e) {
|
||
failure(e);
|
||
} finally {
|
||
b.disabled = false;
|
||
}
|
||
};
|
||
return b;
|
||
}
|
||
// The system app owns the call screen so Home or closing the dialer cannot
|
||
// remove the only hang-up control. States always come from the host modem.
|
||
function syncCallHistory() {
|
||
let stopped = false;
|
||
addEventListener('pagehide', () => { stopped = true; }, {once:true});
|
||
const refresh = async () => {
|
||
try {
|
||
const db = window.CallLogDBManager;
|
||
if (db && window.CallLog && (!window.CallLog._initialized || window.CallLog.sticky)) {
|
||
const entries = await call('platform.callHistory', {});
|
||
for (const item of entries) {
|
||
const entry = {date:item.date, duration:item.duration, number:item.number,
|
||
type:item.direction === 'incoming' ? 'incoming':'dialing',
|
||
status:item.direction === 'incoming' && item.connectedAt !== null ? 'connected':null,
|
||
serviceId:0, emergency:false, voicemail:false};
|
||
// An import interrupted after its DB commit must not duplicate a call.
|
||
const exists = await new Promise((resolve, reject) => {
|
||
db._newTxn('readonly', db._dbGroupsStore, (error, txn, store) => {
|
||
if (error) return reject(Error(error));
|
||
const r = store.get(db._getGroupId(entry));
|
||
r.onsuccess = () => resolve(r.result?.calls?.some(c => c.date === entry.date));
|
||
r.onerror = () => reject(r.error);
|
||
});
|
||
});
|
||
if (!exists) await new Promise((resolve, reject) => db.add(entry, group => {
|
||
if (typeof group === 'string') return reject(Error(group));
|
||
if (window.CallLog.sticky) window.CallLog.appendGroup(group);
|
||
resolve();
|
||
}));
|
||
await call('platform.ackCallHistory', {ids:[item.id]});
|
||
}
|
||
}
|
||
} catch (error) { console.warn('Call history:', error.message); }
|
||
if (!stopped) setTimeout(refresh, 1500);
|
||
};
|
||
refresh();
|
||
}
|
||
if (app === 'system') addEventListener('homescreenopened', () => run('systemReady'));
|
||
window.addEventListener('click', event => {
|
||
if (app !== 'communications' || !event.target.closest?.('#keypad-callbar-call-action')) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
const number = document.getElementById('phone-number-view')?.value;
|
||
if (number) {
|
||
if (window.CallHandler) CallHandler.call(number, 0);
|
||
else window.VulpesPlatformDial(number).catch(() => {});
|
||
}
|
||
}, true);
|
||
async function initialize() {
|
||
const caps = await capabilities;
|
||
// Native calls use Gaia's CallscreenWindow and mozTelephony adapter.
|
||
if (app === 'communications' && location.pathname.startsWith('/dialer') && caps.callControl) syncCallHistory();
|
||
if (app === 'homescreen') {
|
||
// The host can attach Gaia before its first non-zero layout. Retry the
|
||
// icon loader once that size becomes available (desktop, Tundra and APK).
|
||
const watched = new WeakSet();
|
||
const icons = new ResizeObserver((entries) => {
|
||
for (const { target: icon, contentRect } of entries) {
|
||
if (contentRect.width > 0 && icon.size === 0 && typeof icon.refresh === 'function') {
|
||
icon.size = -1;
|
||
icon.refresh();
|
||
}
|
||
}
|
||
});
|
||
const watchIcons = () => {
|
||
for (const icon of document.querySelectorAll('gaia-app-icon')) {
|
||
if (watched.has(icon)) continue;
|
||
watched.add(icon);
|
||
icons.observe(icon);
|
||
}
|
||
};
|
||
new MutationObserver(watchIcons).observe(document.body, { childList: true, subtree: true });
|
||
watchIcons();
|
||
let previous,
|
||
scheduled = false;
|
||
const update = () => {
|
||
scheduled = false;
|
||
const panel = document.querySelector('[aria-hidden="false"] > .scrollable');
|
||
if (!panel || !panel.clientHeight) return;
|
||
const bottom = panel.scrollHeight - panel.clientHeight - panel.scrollTop <= 2;
|
||
if (bottom === previous) return;
|
||
previous = bottom;
|
||
call('desktop.homeScroll', { bottom }).catch(console.error);
|
||
};
|
||
const schedule = () => {
|
||
if (scheduled) return;
|
||
scheduled = true;
|
||
requestAnimationFrame(update);
|
||
};
|
||
document.addEventListener('scroll', schedule, { capture: true, passive: true });
|
||
addEventListener('resize', schedule);
|
||
document.addEventListener('visibilitychange', schedule);
|
||
new MutationObserver(schedule).observe(document.body, {
|
||
subtree: true,
|
||
childList: true,
|
||
attributes: true,
|
||
attributeFilter: ['aria-hidden'],
|
||
});
|
||
const resize = new ResizeObserver(schedule);
|
||
for (const panel of document.querySelectorAll('.scrollable, #apps, #pages'))
|
||
resize.observe(panel);
|
||
schedule();
|
||
}
|
||
if (app === 'system') {
|
||
let previous;
|
||
const navigation = () => {
|
||
const active = document.querySelector('.appWindow.active');
|
||
const home = !!active?.querySelector('iframe[src*="homescreen.localhost"]') &&
|
||
!document.querySelector('#screen.locked, .attentionWindow.active');
|
||
if (home === previous) return;
|
||
previous = home;
|
||
call('desktop.navigation', { home }).catch(console.error);
|
||
};
|
||
new MutationObserver(navigation).observe(document.body, {
|
||
subtree: true,
|
||
childList: true,
|
||
attributes: true,
|
||
attributeFilter: ['class'],
|
||
});
|
||
navigation();
|
||
}
|
||
if (app === 'settings') {
|
||
const add = () => {
|
||
const about = document.querySelector('#about > div');
|
||
if (about && !document.getElementById('vulpes-system-info')) {
|
||
const list = document.createElement('ul');
|
||
list.id = 'vulpes-system-info';
|
||
const row = (key, french, english, value, englishValue = value) => {
|
||
const item = document.createElement('li');
|
||
item.dataset.component = key;
|
||
item.append(
|
||
text(document.createElement('span'), french, english),
|
||
text(
|
||
document.createElement('small'),
|
||
value || 'Non renseigné',
|
||
englishValue || 'Not specified',
|
||
),
|
||
);
|
||
list.append(item);
|
||
};
|
||
row('vulpes', 'Vulpes OS', 'Vulpes OS', caps.version);
|
||
row('gaia', 'Gaia', 'Gaia', caps.gaia);
|
||
row('gecko', 'Gecko', 'Gecko', caps.gecko);
|
||
if (caps.platform === 'tundra') row('tundra', 'Tundra', 'Tundra', caps.tundra);
|
||
else row('platform', 'Plateforme', 'Platform', caps.platformName || host);
|
||
row(
|
||
'model',
|
||
'Modèle',
|
||
'Model',
|
||
caps.model || (android ? 'Android' : 'Ordinateur'),
|
||
caps.model || (android ? 'Android' : 'Computer'),
|
||
);
|
||
const adapter = caps.adapter;
|
||
const version =
|
||
adapter?.version && adapter.version + (adapter.device ? '-' + adapter.device : '');
|
||
row(
|
||
'adapter',
|
||
caps.platform === 'tundra' ? 'Adaptation matérielle' : 'Adaptateur Vulpes',
|
||
caps.platform === 'tundra' ? 'Hardware adaptation' : 'Vulpes adapter',
|
||
version,
|
||
);
|
||
// Keep the legacy nodes for Gaia's panel controller, without duplicate rows.
|
||
for (const key of ['deviceinfo.product_model', 'deviceinfo.software']) {
|
||
const old = about.querySelector('[data-name="' + key + '"]')?.closest('li');
|
||
if (old) old.style.display = 'none';
|
||
}
|
||
const brand = about.querySelector('.vulpes-brand');
|
||
if (brand) brand.after(list);
|
||
else about.prepend(list);
|
||
}
|
||
|
||
};
|
||
new MutationObserver(add).observe(document.body, { childList: true, subtree: true });
|
||
add();
|
||
}
|
||
}
|
||
const loaded =
|
||
document.readyState === 'loading'
|
||
? new Promise((resolve) => addEventListener('DOMContentLoaded', resolve, { once: true }))
|
||
: Promise.resolve();
|
||
loaded.then(initialize).catch(failure);
|
||
})();
|
||
|
||
// Legacy Gaia apps live in cross-origin frames. Their manifest-authorized
|
||
// notifications use the platform bridge; external browser pages keep Gecko's API.
|
||
(() => {
|
||
const { call } = VulpesCompat;
|
||
let permission = 'default';
|
||
const instances = new Map();
|
||
class VulpesNotification extends EventTarget {
|
||
constructor(title, options = {}) {
|
||
super();
|
||
Object.assign(this, {
|
||
title: String(title),
|
||
body: options.body || '',
|
||
tag: options.tag || '',
|
||
data: options.data || null,
|
||
});
|
||
call('platform.notification', {
|
||
operation: 'show',
|
||
title: this.title,
|
||
body: this.body,
|
||
tag: this.tag,
|
||
data: this.data,
|
||
icon: options.icon || '', lang: options.lang || '', dir: options.dir || 'auto',
|
||
silent: options.silent === true,
|
||
}).then(
|
||
(id) => {
|
||
this.id = id;
|
||
instances.set(id, this);
|
||
if (this.closed) this.close();
|
||
else this.emit('show');
|
||
},
|
||
(error) => {
|
||
this.error = error;
|
||
this.emit('error');
|
||
},
|
||
);
|
||
}
|
||
emit(type) {
|
||
const event = new Event(type);
|
||
this.dispatchEvent(event);
|
||
this['on' + type]?.(event);
|
||
}
|
||
close() {
|
||
this.closed = true;
|
||
if (this.id != null)
|
||
call('platform.notification', { operation: 'close', id: this.id })
|
||
.then(() => {
|
||
instances.delete(this.id);
|
||
this.emit('close');
|
||
})
|
||
.catch(console.warn);
|
||
}
|
||
static get permission() {
|
||
return permission;
|
||
}
|
||
static async requestPermission(callback) {
|
||
permission = await call('platform.notification', { operation: 'permission' });
|
||
callback?.(permission);
|
||
return permission;
|
||
}
|
||
static async get(filter = {}) {
|
||
const rows = await call('platform.notification', { operation: 'list' });
|
||
return rows
|
||
.filter((row) => !filter.tag || row.tag === filter.tag)
|
||
.map((row) => {
|
||
if (instances.has(row.id)) return instances.get(row.id);
|
||
const n = new EventTarget();
|
||
Object.setPrototypeOf(n, VulpesNotification.prototype);
|
||
Object.assign(n, row);
|
||
instances.set(row.id, n);
|
||
return n;
|
||
});
|
||
}
|
||
}
|
||
Object.defineProperty(window, 'Notification', { value: VulpesNotification, configurable: true });
|
||
call('apps.self', {})
|
||
.then((app) =>
|
||
app.manifest.permissions?.['desktop-notification']
|
||
? call('platform.notification', { operation: 'query' })
|
||
: 'denied',
|
||
)
|
||
.then(
|
||
(value) => (permission = value),
|
||
() => {},
|
||
);
|
||
addEventListener('vulpes-service-event', ({ detail }) => {
|
||
if (detail.type === 'notification-close') {
|
||
instances.get(detail.data.id)?.emit('close');instances.delete(detail.data.id);
|
||
}
|
||
if (detail.type === 'notification-click') {
|
||
instances.get(detail.data.id)?.emit('click');
|
||
instances.delete(detail.data.id);
|
||
}
|
||
});
|
||
})();
|
||
|
||
// Route the native tray through Gaia's original toast, lockscreen and sound logic.
|
||
if (location.hostname === 'system.localhost' && window.VulpesPlatform === 'tundra') {
|
||
const deliver = detail => dispatchEvent(new CustomEvent('mozChromeNotificationEvent',{detail}));
|
||
addEventListener('vulpes-service-event',({detail})=>{
|
||
if (detail.type==='gaia-notification') deliver(detail.data);
|
||
});
|
||
addEventListener('mozContentNotificationEvent',event=>{
|
||
VulpesCompat.call('platform.notificationEvent',event.detail).catch(console.error);
|
||
});
|
||
const restore = setInterval(async()=>{
|
||
if (!window.NotificationScreen?.container) return;
|
||
clearInterval(restore);
|
||
try {
|
||
for (const row of await VulpesCompat.call('platform.notificationEvent',{type:'resend'}))
|
||
deliver({...row,mozbehavior:{...row.mozbehavior,noscreen:true,nosound:true,novibrate:true}});
|
||
} catch(error) {console.error(error);}
|
||
},500);
|
||
}
|