import {mmsPayload} from 'resource://vulpes/services/mms.mjs'; import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs'; import {Input} from 'resource://vulpes/host/Input.sys.mjs'; import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs'; import { Packages } from 'resource://vulpes/services/packages.mjs'; import { Activities, isGaiaActivity } from 'resource://vulpes/services/activities.mjs'; import { SettingsService } from 'resource://vulpes/services/settings.mjs'; import { DataStoreService } from 'resource://vulpes/services/datastores.mjs'; import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs'; import { Views } from 'resource://vulpes/host/Views.sys.mjs'; import { Connections } from 'resource://vulpes/host/Connections.sys.mjs'; import { Contacts } from 'resource://vulpes/host/Contacts.sys.mjs'; import { Messages } from 'resource://vulpes/host/Messages.sys.mjs'; import { Alarms } from 'resource://vulpes/host/Alarms.sys.mjs'; import { Platform } from 'resource://vulpes/host/Platform.sys.mjs'; import { Media } from 'resource://vulpes/host/Media.sys.mjs'; import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs'; Cu.importGlobalProperties(['indexedDB']); // This is the only privileged owner of application identity and settings data. // Application messages are never allowed to supply their own permissions. export const Runtime = { apps: [], actors: new Set(), windows: new Map(), power: { screenEnabled: true, screenBrightness: 1, keyLightEnabled: false, cpuSleepAllowed: true, }, wakeLocks: new Map(), idleListeners: new Map(), cleanup(actor) { this.activities?.cleanup(actor); this.input?.cleanup(actor); this.platform?.nativeCamera.cleanup(actor); this.connections?.cleanup(actor); this.alarms?.cleanup(actor); Views.cleanup(actor); this.actors.delete(actor); this.wakeLocks.delete(actor); const idle = Cc['@mozilla.org/widget/useridleservice;1'].getService(Ci.nsIUserIdleService); for (const { observer, time } of this.idleListeners.get(actor)?.values() || []) idle.removeIdleObserver(observer, time); this.idleListeners.delete(actor); }, async init() { this.input = new Input(this); const root = Services.env.get('VULPES_WORKSPACE_ROOT'); this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json')); const storeURL = 'http://system.localhost:8765/_vulpes/packages/'; const store = async (operation, item) => { const token = await IOUtils.readUTF8(PathUtils.join(root,'profiles','installed-apps','.token')); const response = await fetch(storeURL+operation,{method:'POST',headers:{'X-Vulpes-Install':token,'Content-Type':'application/json'},body:JSON.stringify(item)}); if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED'); return response.json(); }; this.refreshInstalled = async () => { const response=await fetch(storeURL+'list');if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED'); const installed=await response.json(); this.apps.splice(0,this.apps.length,...this.apps.filter(a=>!a.installed),...installed); return installed; }; this.packages = new Packages({schedule:setTimeout,unschedule:clearTimeout,save:item=>store('save',item),changed:()=>this.refreshInstalled(),uuid:()=>Services.uuid.generateUUID().toString()}); this.removePackage = async id => {const app=this.apps.find(a=>a.id===id && a.installed);if(!app)throw Error('NOT_REMOVABLE'); await store('remove',{id});await this.refreshInstalled();this.broadcast('apps-uninstalled',app);}; // Installed Gaia audio apps must be able to ring when an alarm fires. This // permission is scoped to their origins in this profile, never web pages. for (const app of this.apps) if (Object.keys(app.manifest.permissions || {}).some((p) => p.startsWith('audio-channel-'))) { const principal = Services.scriptSecurityManager.createContentPrincipal( Services.io.newURI(app.origin), {}, ); Services.perms.addFromPrincipal( principal, 'autoplay-media', Ci.nsIPermissionManager.ALLOW_ACTION, ); } const defaults = await IOUtils.readJSON( PathUtils.join(root, 'assets', 'settings-current-defaults.json'), ); const build = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'build-info.json')); this.release = build.release; const path = PathUtils.join(PathUtils.profileDir, 'vulpes-settings-v1.json'); const saved = (await IOUtils.exists(path)) ? await IOUtils.readJSON(path) : {}; this.values = { ...defaults, 'ftu.manifestURL': '', 'lockscreen.enabled': true, 'screen.timeout': 0, 'language.current': 'fr', ...saved, 'software-button.enabled': false, // The desktop shell already provides Home. 'deviceinfo.os': this.release.gaia, 'deviceinfo.previous_os': '2.7', 'deviceinfo.software': 'Vulpes OS ' + this.release.version, 'deviceinfo.product_model': 'Project Vulpes desktop', 'deviceinfo.product_device': 'desktop', 'deviceinfo.product_manufacturer': 'Project Vulpes', 'deviceinfo.hardware': Services.appinfo.OS, 'deviceinfo.firmware_revision': 'Desktop — sans firmware téléphone', 'deviceinfo.build_number': build.revision.slice(0, 12) + (build.dirty ? '+local' : ''), 'deviceinfo.platform_version': Services.appinfo.platformVersion, 'deviceinfo.platform_build_id': Services.appinfo.platformBuildID, }; if (!saved['vulpes.gaia-restoration.v1']) { this.values['lockscreen.enabled'] = true; this.values['vulpes.gaia-restoration.v1'] = true; } Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']); this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps, choose:data=>this.broadcast('activity-choice',data,'system'), uuid:()=>Services.uuid.generateUUID().toString(), notify:(actor,type,data)=>actor.sendAsyncMessage('Vulpes:Event',{type,data}), launch:(target,path,{reload=false,entryPoint}={})=>this.broadcast(reload?'activity-launch':'launch',{manifestURL:target.manifestURL,origin:target.origin, entryPoint,url:new URL(path || target.manifest.launch_path || '/index.html',target.origin).href,timestamp:Date.now()},'system')}); this.writeQueue = Promise.resolve(); this.settings = new SettingsService({ get: async (key) => this.values[key], setMany: (entries) => { const write = this.writeQueue.then(async () => { const values = { ...this.values, ...Object.fromEntries(entries) }; await IOUtils.writeJSON(path, values, { tmpPath: path + '.tmp' }); this.values = values; const prefMap={ 'ui.click_hold_context_menus.delay':'ui.click_hold_context_menus.delay', 'accessibility.monoaudio.enable':'accessibility.monoaudio.enable', 'geolocation.enabled':'geo.enabled', 'privacy.donottrackheader.enabled':'privacy.donottrackheader.enabled', 'debug.paint-flashing.enabled':'nglayout.debug.paint_flashing', 'apz.overscroll.enabled':'apz.overscroll.enabled' }; for(const [key,value] of entries) { const pref=prefMap[key];if(!pref)continue; if(typeof value==='boolean')Services.prefs.setBoolPref(pref,value); else if(key==='ui.click_hold_context_menus.delay' && [400,800,1600].includes(value))Services.prefs.setIntPref(pref,value); } if (entries.some(([key]) => key === 'language.current')) Services.prefs.setStringPref('intl.accept_languages', values['language.current']); if (Services.env.get('VULPES_TUNDRA') === '1') { const volume = entries.find(([key, value]) => /^audio\.volume\.(content|notification|alarm|telephony|bt_sco)$/.test(key) && Number.isFinite(value)); if (volume) { const maximum = /\.(telephony|bt_sco)$/.test(volume[0]) ? 5 : 15; const level = Math.max(0,Math.min(1,volume[1]/maximum)); const control = maximum === 5 ? tundraCallAudio({operation:'set',volume:level}) : tundraHardware({operation:'volume',level}); await control.catch(error => console.error(error.message)); } } for (const [key, value] of entries) this.broadcast('settingchange', { key, value }); }); this.writeQueue = write.catch(() => {}); return write; }, }, {maxBytes:32 * 1024 * 1024}); const storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () => Services.uuid.generateUUID().toString(), ); this.connections = new Connections( (actor) => this.identity(actor), (actor, type, data) => actor.sendAsyncMessage('Vulpes:Event', { type, data }), ); this.contacts = new Contacts(storage, (data) => this.broadcast('contactchange', data)); await this.contacts.init(); this.messages = new Messages( storage, Services.env.get('VULPES_TUNDRA_RADIO') === '1' ? ({ number, body }) => tundraRadio('sms', { number, body }) : null, (type, message) => this.broadcast('sms-' + type, message, 'sms'), path => tundraRadio('messageState', {path}), Services.env.get('VULPES_TUNDRA_RADIO') === '1' ? {validate:mmsPayload, send:message=>tundraMms('send',mmsPayload(message)), status:path=>tundraMms('state',{path})} : null, ); this.media = new Media((data) => this.broadcast('mediachange', data)); this.platform = new Platform(this); await this.platform.init(); if (Services.env.get('VULPES_TUNDRA') === '1') { Object.assign(this.values, { 'deviceinfo.product_model': 'Google Pixel 3a', 'deviceinfo.product_device': 'sargo', 'deviceinfo.product_manufacturer': 'Google', 'deviceinfo.hardware': 'Tundra / Sargo', 'deviceinfo.firmware_revision': 'Android 9 / API 28 (vendor)', }); } const alarmPath = PathUtils.join(PathUtils.profileDir, 'vulpes-alarms-v1.json'); this.alarms = new Alarms( (actor) => this.identity(actor), (id) => { const app = this.apps.find((a) => a.id === id); if (!app) return; const path = app.manifest.messages?.find((m) => m.alarm)?.alarm; if (!path) return; this.broadcast( 'launch', { manifestURL: app.manifestURL, origin: app.origin, url: app.origin + path, timestamp: Date.now(), }, 'system', ); }, { load: async () => ((await IOUtils.exists(alarmPath)) ? IOUtils.readJSON(alarmPath) : null), save: (state) => IOUtils.writeJSON(alarmPath, state, { tmpPath: alarmPath + '.tmp' }), }, setInterval, ); await this.alarms.init(); this.dataStores = new DataStoreService(this.apps, storage, (data) => this.broadcast('datastorechange', data), ); }, identity(actor) { const origin = actor.manager.documentPrincipal.originNoSuffix; const top = actor.browsingContext.top.embedderElement; if ( !top || top.id !== 'system' || top.ownerDocument.documentURI !== 'chrome://vulpes/content/shell.xhtml' ) return null; return this.apps.find((app) => app.origin === origin) || null; }, grants(app) { const access = app.manifest.permissions?.settings?.access || ''; return [ access.includes('read') && 'settings.read', access.includes('write') && 'settings.write', ].filter(Boolean); }, async request(actor, request) { const app = this.identity(actor); if (!app) return { error: { code: 'PERMISSION_DENIED' } }; if (!request || request.version !== 1 || !Number.isSafeInteger(request.id) || request.id < 0) { return { error: { code: 'INVALID_REQUEST' } }; } this.actors.add(actor); const { method, params } = request; if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } }; if(method.startsWith('marketplace.')) { if(app.id!=='marketplace')return {error:{code:'PERMISSION_DENIED'}}; try { if(method==='marketplace.poll'){const url=this.marketplaceDownload;this.marketplaceDownload=null;return {result:url||null};} if(method==='marketplace.cancel'){this.packages.cancel();return {result:true};} if(method==='marketplace.prepare')return {result:await this.packages.prepare(params.url)}; if(method==='marketplace.install') {const id=await this.packages.install(params.token);const installed=this.apps.find(a=>a.id===id);this.broadcast('apps-installed',installed);return {result:installed};} } catch(error) {return {error:{code:error.message}};} return {error:{code:'METHOD_NOT_SUPPORTED'}}; } if(method==='hardware.vibrate') { if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}}; const pattern=params?.pattern; if(!Array.isArray(pattern) || pattern.length>32 || pattern.some(n=>!Number.isInteger(n) || n<0 || n>10000) || pattern.reduce((a,b)=>a+b,0)>10000) return {error:{code:'INVALID_VALUE'}}; try {await tundraHardware({operation:'vibrate',pattern});return {result:true};} catch(error) {return {error:{code:error.message}};} } if(method==='input.request') { try {return {result:await this.input.request(actor,params)};} catch(error) {return {error:{code:error.message}};} } if (method.startsWith('platform.')) { try { return { result: await this.platform.request(actor, app, method.slice(9), params || {}) }; } catch (error) { return { error: { code: error.message || 'PLATFORM_ERROR' } }; } } if (method?.startsWith('iac.')) { try { if (method === 'iac.subscribe') { this.connections.subscribe(actor); return { result: null }; } if (method === 'iac.connect') return { result: this.connections.connect(actor, params.keyword, this.apps) }; if (method === 'iac.send') return { result: this.connections.send(actor, params) }; if (method === 'iac.close') { this.connections.close(actor, params.id); return { result: null }; } } catch (error) { return { error: { code: error.message } }; } } if (method === 'contacts.request') { try { return { result: await this.contacts.request(app, params) }; } catch (error) { return { error: { code: error.message || 'CONTACTS_ERROR' } }; } } if (method === 'alarms.request') { try { return { result: await this.alarms.request(actor, app, params) }; } catch (error) { return { error: { code: error.message || 'ALARMS_ERROR' } }; } } if (method === 'media.request') { try { return { result: await this.media.request(app, params) }; } catch (error) { return { error: { code: error.message || 'MEDIA_ERROR' } }; } } if (method === 'messages.request') { try { return { result: await this.messages.request(app, params) }; } catch (error) { return { error: { code: error.message || 'MESSAGES_ERROR' } }; } } if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } }; if (['activities.subscribe','activities.finish','activities.cancel','activities.select'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) { try { const result = method === 'activities.select' ? this.activities.select(actor,params) : method === 'activities.cancel' ? this.activities.cancel(actor) : method === 'activities.subscribe' ? this.activities.subscribe(actor) : method === 'activities.finish' ? this.activities.finish(actor,params) : await this.activities.start(actor,params); return {result:result ?? null}; } catch(error) {return {error:{code:error.message}};} } if (method === 'activities.start') { if (params?.name === 'record' && params.data?.type === 'photos') { // Gallery and the lockscreen open Gaia Camera without expecting a file result. if (['gallery', 'system'].includes(app.id)) { const camera = this.apps.find((a) => a.id === 'camera'); if (!camera) return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } }; this.broadcast( 'launch', { manifestURL: camera.manifestURL, origin: camera.origin, url: camera.origin + (camera.manifest.launch_path || '/index.html'), timestamp: Date.now(), }, 'system', ); return { result: null }; } try { return { result: await this.platform.capture(actor, app) }; } catch (error) { return { error: { code: error.message } }; } } if (params?.name !== 'view' || params?.data?.type !== 'url') return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } }; let uri; try { uri = Services.io.newURI(params.data.url); } catch (_) { return { error: { code: 'INVALID_URL' } }; } if (!['http', 'https'].includes(uri.scheme)) return { error: { code: 'URL_SCHEME_DENIED' } }; this.broadcast( 'activity-view', { source: { name: 'view', data: { type: 'url', url: uri.spec } } }, 'system', ); return { result: null }; } if (method === 'views.request') { if (!app.manifest.permissions?.browser && !app.manifest.permissions?.['embed-apps']) return { error: { code: 'PERMISSION_DENIED' } }; try { return { result: await Views.request(actor, params) }; } catch (error) { return { error: { code: error.message || 'VIEW_ERROR' } }; } } if (method === 'storage.stats') { try {const {storageStats}=ChromeUtils.importESModule('resource://vulpes/host/StorageStats.sys.mjs');return {result:await storageStats(app)};} catch(error) {return {error:{code:error.message || 'STORAGE_ERROR'}};} } if (method === 'downloads.request') { try { const {GaiaDownloads}=ChromeUtils.importESModule('resource://vulpes/host/Downloads.sys.mjs'); this.downloads ||= new GaiaDownloads(); return {result:await this.downloads.request(app,params)}; } catch(error) {return {error:{code:error.message || 'DOWNLOAD_ERROR'}};} } if (method === 'appearance.current') return { result: this.values['vulpes.theme'] || 'light' }; if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' }; if (method === 'settings.snapshot') return this.grants(app).includes('settings.read') ? { result: { ...this.values } } : { error: { code: 'PERMISSION_DENIED' } }; if (method === 'settings.clockChanged') { if(app.id !== 'settings' || Services.env.get('VULPES_TUNDRA') !== '1') return {error:{code:'PERMISSION_DENIED'}}; Services.obs.notifyObservers(null, 'system-timezone-changed'); return {result:true}; } if (method.startsWith('settings.')) return this.settings.bindSession(app.id, this.grants(app))(request); if (method === 'desktop.idle') { if (!app.manifest.permissions?.idle) return { error: { code: 'PERMISSION_DENIED' } }; const { token, time, add } = params || {}; if ( typeof token !== 'string' || token.length > 80 || !Number.isInteger(time) || time < 1 || time > 3600 || typeof add !== 'boolean' ) return { error: { code: 'INVALID_VALUE' } }; let listeners = this.idleListeners.get(actor); if (!listeners) { listeners = new Map(); this.idleListeners.set(actor, listeners); } const idle = Cc['@mozilla.org/widget/useridleservice;1'].getService(Ci.nsIUserIdleService); const previous = listeners.get(token); if (previous) { idle.removeIdleObserver(previous.observer, previous.time); listeners.delete(token); } if (add) { if (listeners.size >= 16) return { error: { code: 'QUOTA_EXCEEDED' } }; const observer = { observe(subject, topic) { actor.sendAsyncMessage('Vulpes:Event', { type: 'idle', data: { token, idle: topic === 'idle' }, }); }, }; idle.addIdleObserver(observer, time); listeners.set(token, { observer, time }); } return { result: null }; } if (method === 'desktop.homeScroll') { if (app.id !== 'homescreen' || typeof params?.bottom !== 'boolean') return { error: { code: 'PERMISSION_DENIED' } }; actor.browsingContext.top.embedderElement.ownerDocument.defaultView.setHomeScrollBottom( params.bottom, ); return { result: null }; } if (method === 'desktop.navigation') { if (app.id !== 'system' || typeof params?.home !== 'boolean') return { error: { code: 'PERMISSION_DENIED' } }; actor.browsingContext.top.embedderElement.ownerDocument.defaultView.setHomeSurface( params.home, ); return { result: null }; } if (method === 'desktop.screen') { if (!app.manifest.permissions?.power) return { error: { code: 'PERMISSION_DENIED' } }; const { name, value } = params || {}; if ( !Object.hasOwn(this.power, name) || (name === 'screenBrightness' ? !Number.isFinite(value) || value < 0 || value > 1 : typeof value !== 'boolean') ) return { error: { code: 'INVALID_VALUE' } }; this.screenPower ||= new ScreenPower(this.power, async (next, changed) => { if (Services.env.get('VULPES_TUNDRA') === '1' && ['screenEnabled','screenBrightness'].includes(changed)) await tundraHardware({operation:'screen', enabled:next.screenEnabled, level:next.screenBrightness}); }); await this.screenPower.set(name, value); const screen = actor.browsingContext.top.embedderElement.ownerDocument.getElementById('screen'); screen.style.filter = Services.env.get('VULPES_TUNDRA') === '1' ? '' : `brightness(${this.power.screenEnabled ? this.power.screenBrightness : 0})`; screen.inert = !this.power.screenEnabled; return { result: { ...this.power } }; } if (method === 'desktop.wakelock') { const { topic, token, acquire } = params || {}; if ( !['screen', 'cpu', 'high-priority'].includes(topic) || typeof token !== 'string' || token.length > 80 || typeof acquire !== 'boolean' ) return { error: { code: 'INVALID_VALUE' } }; let locks = this.wakeLocks.get(actor); if (!locks) { locks = new Map(); this.wakeLocks.set(actor, locks); } if (acquire) locks.set(token, topic); else locks.delete(token); this.broadcast( 'wakelockchange', { topic, state: Array.from(this.wakeLocks.values()).some((map) => Array.from(map.values()).includes(topic), ) ? 'locked-foreground' : 'unlocked', }, 'system', ); return { result: null }; } if (method === 'datastores.list') return { result: this.dataStores.list(app, params?.name) }; if (method === 'datastores.run') { try { return { result: await this.dataStores.run(app, params) }; } catch (error) { return { error: { code: error.message || 'DATASTORE_ERROR' } }; } } const manage = app.manifest.permissions?.['webapps-manage'] || app.manifest.permissions?.['homescreen-webapps-manage']; if (method === 'apps.self') return { result: app }; if (method === 'apps.list') return manage ? { result: this.apps } : { error: { code: 'PERMISSION_DENIED' } }; if(method==='apps.uninstall') { if(!manage)return {error:{code:'PERMISSION_DENIED'}}; try {await this.removePackage(params.id);return {result:true};}catch(error){return {error:{code:error.message}};} } if (method === 'apps.launch') { const target = this.apps.find((a) => a.manifestURL === params?.manifestURL); if (!target || (!manage && app.id !== target.id)) return { error: { code: 'PERMISSION_DENIED' } }; const path = target.manifest.entry_points?.[params?.entryPoint]?.launch_path || target.manifest.launch_path || '/index.html'; this.broadcast( 'launch', { manifestURL: target.manifestURL, origin: target.origin, url: target.origin + path, entryPoint: params?.entryPoint, timestamp: Date.now(), }, 'system', ); return { result: null }; } return { error: { code: 'METHOD_NOT_SUPPORTED' } }; }, broadcast(type, data, target) { for (const actor of this.actors) { try { const app = this.identity(actor); if (!app || (target && app.id !== target)) continue; if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !this.grants(app).includes('settings.read')) continue; if ( type === 'mediachange' && !app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read') ) continue; if ( type === 'contactchange' && !app.manifest.permissions?.contacts?.access?.includes('read') ) continue; if ( type === 'datastorechange' && !this.dataStores.list(app, data.name).some((s) => s.owner === data.owner) ) continue; actor.sendAsyncMessage('Vulpes:Event', { type, data }); } catch (_) { this.actors.delete(actor); } } }, };