import {Input} from './services/input.mjs'; import { Packages, packageURL } from './services/packages.mjs'; import { Activities, isGaiaActivity } from './services/activities.mjs'; import { authorizePlatform } from './services/platform.mjs'; import { SettingsService } from './services/settings.mjs'; import { DataStoreService } from './services/datastores.mjs'; import { DataStoresIndexedDB } from './adapters/datastores-indexeddb.mjs'; import { Contacts } from './Contacts.mjs'; import { Messages } from './Messages.mjs'; import { Connections } from './Connections.mjs'; import { Alarms } from './Alarms.mjs'; const peers = new Set(); let activities, packages; const input = new Input({identity:peer=>peer.app,broadcast}); let editSequence=0; const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse); async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);} let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage; const identity = (peer) => peer.app; function notify(peer, type, data) { try { peer.port.postMessage({ event: { type, data } }); } catch (_) { peers.delete(peer); } } function grants(app) { const access = app.manifest.permissions?.settings?.access || ''; return [ access.includes('read') && 'settings.read', access.includes('write') && 'settings.write', ].filter(Boolean); } function broadcast(type, data, target) { for (const peer of peers) { if (target && peer.app.id !== target) continue; if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue; if ( type === 'contactchange' && !peer.app.manifest.permissions?.contacts?.access?.includes('read') ) continue; if ( type === 'datastorechange' && !dataStores.list(peer.app, data.name).some((s) => s.owner === data.owner) ) continue; if ( type === 'mediachange' && !peer.app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read') ) continue; notify(peer, type, data); } } function launch(target, entryPoint, path, reload = false) { path ||= target.manifest.entry_points?.[entryPoint]?.launch_path || target.manifest.launch_path || '/index.html'; const url = new URL(path, target.origin); if (url.origin !== target.origin) throw Error('INVALID_URL'); broadcast( reload ? 'activity-launch' : 'launch', { manifestURL: target.manifestURL, origin: target.origin, url: url.href, entryPoint, timestamp: Date.now(), }, 'system', ); } const ready = (async () => { apps = await (await fetch('registry.json')).json(); await refreshInstalled(); packages=new Packages({save:item=>nativePackages('save',{item}),changed:refreshInstalled}); const defaults = await (await fetch('defaults.json')).json(); const saved = await browser.storage.local.get('settings'); values = { ...defaults, ...saved.settings }; if (!values['vulpes.gaia-restoration.v1']) { values['lockscreen.enabled'] = true; values['vulpes.gaia-restoration.v1'] = true; await browser.storage.local.set({settings:values}); } // Preview 1 serialized wallpaper Blobs as {}. Repair that one invalid value // on upgrade, retaining all other user settings and valid custom wallpapers. const wallpaper = values['wallpaper.image']; if (wallpaper && typeof wallpaper === 'object' && !Object.keys(wallpaper).length) { values['wallpaper.image'] = defaults['wallpaper.image']; values['wallpaper.image.valid'] = false; await browser.storage.local.set({ settings: values }); } activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(), choose:data=>broadcast('activity-choice',data,'system'), launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)}); let writes = Promise.resolve(); settings = new SettingsService( { get: async (key) => values[key], setMany(entries) { const write = writes.then(async () => { const next = { ...values, ...Object.fromEntries(entries) }; await browser.storage.local.set({ settings: next }); values = next; for (const [key, value] of entries) { broadcast('settingchange', { key, value }); if(key==='keyboard.gaia.enabled') { input.hide();broadcast('input-mode',{enabled:value===true}); } } }); writes = write.catch(() => {}); return write; }, }, { maxBytes: 32 * 1024 * 1024 }, ); // Allows the bounded base64 image envelope on high-DPI Android screens. storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () => crypto.randomUUID()); contacts = new Contacts(storage, (data) => broadcast('contactchange', data)); await contacts.init(); messages = new Messages(storage); dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data)); connections = new Connections(identity, notify); alarms = new Alarms( identity, (id) => { const app = apps.find((a) => a.id === id); if (app) launch(app, undefined, app.manifest.messages?.find((m) => m.alarm)?.alarm); }, { load: async () => (await browser.storage.local.get('alarms')).alarms, save: (state) => browser.storage.local.set({ alarms: state }), }, (callback, delay) => setInterval(callback, delay), ); await alarms.init(); console.info('VULPES Android services ready'); })(); // Java owns this port. Web pages have no access to native messaging. let native; let systemReady = false; let pendingScreenLock = false; function deliverScreenLock() { if (!systemReady || !pendingScreenLock) return; pendingScreenLock = false; broadcast('screen-off', {}, 'system'); } let pendingNotification; function deliverNotification() { if (!systemReady || !pendingNotification) return; const data = pendingNotification; pendingNotification = null; const target = apps.find((a) => a.id === data.owner); if (target) { launch(target); broadcast('notification-click', data, target.id); } } function connectNative() { native = browser.runtime.connectNative('vulpes'); native.onDisconnect.addListener(() => setTimeout(connectNative, 1000)); native.onMessage.addListener(async (message) => { await ready; if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system'); if (message.type === 'android-screen-off') { pendingScreenLock = true; deliverScreenLock(); } if (message.type === 'android-resume') { deliverScreenLock(); broadcast('screen-on', {}, 'system'); await refreshAndroid().catch(console.warn); } if (message.type === 'notification-click') { pendingNotification = message.data; deliverNotification(); } }); } connectNative(); async function android(type, params = {}) { const reply = await browser.runtime.sendNativeMessage('vulpes', { type: 'android.' + type, params, }); const result = typeof reply === 'string' ? JSON.parse(reply).result : reply; if (result?.error) throw Error(result.error); return result; } async function refreshAndroid() { const snapshot = await android('snapshot'); for (const [key, value] of Object.entries(snapshot)) { if (values[key] === value) continue; values[key] = value; broadcast('settingchange', { key, value }); } return snapshot; } ready.then(refreshAndroid).catch(console.warn); async function handle(peer, request) { const app = peer.app; if ( !request || request.version !== 1 || !Number.isSafeInteger(request.id) || request.id < 0 || typeof request.method !== 'string' ) throw Error('INVALID_REQUEST'); const { params = {} } = request; let method = request.method; if (method.startsWith('platform.')) { authorizePlatform(app, method.slice(9), params); method = 'android.' + method.slice(9); } if (method === 'android.capabilities') { const release = await (await fetch('release.json')).json(); const device = app.id === 'settings' ? await android('snapshot') : {}; return { platform: 'android', platformName: device['deviceinfo.firmware_revision'] || 'Android', model: device['deviceinfo.product_model'], adapter: { version: release.adapters?.android, device: 'android' }, version: release.version, gaia: release.gaia, gecko: release.androidEngine, camera: true, media: true, contacts: true, notifications: true, dial: true, sms: true, alarms: true, }; } if (method === 'android.systemReady') { if (app.id !== 'system') throw Error('PERMISSION_DENIED'); systemReady = true; deliverScreenLock(); deliverNotification(); return null; } if (method === 'android.notification') { if (!app.manifest.permissions?.['desktop-notification']) throw Error('PERMISSION_DENIED'); if (JSON.stringify(params).length > 65536) throw Error('QuotaExceededError'); return android('notification', { ...params, owner: app.id }); } if (method === 'android.settings' || method === 'android.snapshot') { if (!['settings', 'system', 'costcontrol'].includes(app.id)) throw Error('PERMISSION_DENIED'); return method === 'android.snapshot' ? refreshAndroid() : android('settings', params); } if (method === 'android.capture') { if (app.id !== 'camera') throw Error('PERMISSION_DENIED'); const names = await android('capture'); for (const path of names) broadcast('mediachange', { type: 'pictures', path, reason: 'modified' }); return names; } if (method === 'android.pick') { const access = app.manifest.permissions?.['device-storage:' + params.type]?.access || ''; if (!access.includes('write')) throw Error('PERMISSION_DENIED'); const names = await android('pick', params); for (const path of names) broadcast('mediachange', { type: params.type, path, reason: 'modified' }); return names; } if (method === 'android.alarms') { if (app.id !== 'clock') throw Error('PERMISSION_DENIED'); return android('alarms'); } if (method === 'android.contact') { if (!app.manifest.permissions?.contacts?.access?.includes('write')) throw Error('PERMISSION_DENIED'); const contact = await android('contact'); return contacts.request(app, { operation: 'save', contact }); } if (method === 'android.sms') { if (app.id !== 'sms') throw Error('PERMISSION_DENIED'); return android('sms', params); } if (method === 'android.dial') { if (app.id !== 'communications') throw Error('PERMISSION_DENIED'); return android('dial', params); } if (method === 'settings.set' && params.values && typeof params.values === 'object') { const validation = await new SettingsService( { setMany: async () => {} }, { maxBytes: 32 * 1024 * 1024 }, ).bindSession( app.id, grants(app), )(request); if (validation.error) throw Error(validation.error.code); for (const [key, value] of Object.entries(params.values)) { if (app.id === 'settings' && ['screen.brightness', 'audio.volume.content'].includes(key)) await android('set', { key, value }); const panel = { 'wifi.enabled': 'wifi', 'bluetooth.enabled': 'bluetooth', 'airplaneMode.enabled': 'airplane-mode', 'geolocation.enabled': 'geolocation', 'ril.data.enabled': 'data', }[key]; if (panel) { // UI clicks open the native panel. Gaia startup writes must not open Android activities. // Android controls these values; never persist a fictitious toggle state. delete params.values[key]; } } if (!Object.keys(params.values).length) return null; } if (method === 'input.config') return values['keyboard.gaia.enabled'] === true; if (method === 'input.request') return input.request(peer,params); if (method === 'appearance.current') return values['vulpes.theme'] || 'light'; if (method === 'locale.current') return values['language.current'] || 'en-US'; if (method === 'settings.snapshot') { if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED'); return { ...values }; } if (method.startsWith('settings.')) { const reply = await settings.bindSession(app.id, grants(app))(request); if (reply.error) throw Error(reply.error.code); return reply.result; } if(method.startsWith('marketplace.')) { if(app.id!=='marketplace')throw Error('PERMISSION_DENIED'); if(method==='marketplace.poll'){const url=marketplaceDownload;marketplaceDownload=null;return url;} if(method==='marketplace.cancel'){packages.cancel();return true;} if(method==='marketplace.prepare')return packages.prepare(params.url); if(method==='marketplace.install'){const id=await packages.install(params.token);const installed=apps.find(a=>a.id===id);broadcast('apps-installed',installed);return installed;} throw Error('METHOD_NOT_SUPPORTED'); } if (method === 'apps.self') { console.info('VULPES app ready', app.id); return app; } const manage = app.manifest.permissions?.['webapps-manage'] || app.manifest.permissions?.['homescreen-webapps-manage']; if (method === 'apps.list') { if (!manage) throw Error('PERMISSION_DENIED'); return apps; } if(method==='apps.uninstall') { const installed=apps.find(a=>a.id===params.id && a.installed);if(!manage || !installed)throw Error('PERMISSION_DENIED'); await nativePackages('remove',{id:params.id});await refreshInstalled();broadcast('apps-uninstalled',installed);return true; } if (method === 'apps.launch') { const target = apps.find((a) => a.manifestURL === params.manifestURL); if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED'); launch(target, params.entryPoint); return null; } if (method === 'datastores.list') return dataStores.list(app, params.name); if (method === 'datastores.run') return dataStores.run(app, params); if (method === 'contacts.request') return contacts.request(app, params); if (method === 'messages.request') return messages.request(app, params); if (method === 'alarms.request') return alarms.request(peer, app, params); if (method === 'iac.subscribe') { connections.subscribe(peer); return null; } if (method === 'iac.connect') return connections.connect(peer, params.keyword, apps); if (method === 'iac.send') return connections.send(peer, params); if (method === 'iac.close') { connections.close(peer, params.id); return null; } if (method === 'activities.cancel') return activities.cancel(peer); if (method === 'activities.select') return activities.select(peer,params); if (method === 'activities.subscribe') return activities.subscribe(peer); if (method === 'activities.finish') return activities.finish(peer,params); if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params); if (method === 'activities.start') { if (params.name === 'record' && params.data?.type === 'photos') { if (['gallery', 'system'].includes(app.id)) { const camera = apps.find((a) => a.id === 'camera'); if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED'); launch(camera); return null; } if (!app.manifest.permissions?.['device-storage:pictures']?.access?.includes('write')) throw Error('PERMISSION_DENIED'); const names = await android('capture'); for (const path of names) broadcast('mediachange', { type: 'pictures', path, reason: 'modified' }); return { filenames: names }; } if (params.name !== 'view' || params.data?.type !== 'url') throw Error('ACTIVITY_NOT_SUPPORTED'); return openWeb(params.data.url); } if (method === 'views.request') { if (!app.manifest.permissions?.browser && !app.manifest.permissions?.['embed-apps']) throw Error('PERMISSION_DENIED'); if (params.operation === 'open') { await openWeb(params.url); notify(peer, 'view', { contextId: params.contextId, type: 'loadend', detail: { backgroundColor: 'white' }, }); return null; } if (['geometry', 'cancelTouch', 'close'].includes(params.operation)) return null; if (['canGoBack', 'canGoForward'].includes(params.operation)) return false; throw Error('NotSupportedError'); } if (method === 'desktop.idle') { if (!app.manifest.permissions?.idle) throw Error('PERMISSION_DENIED'); return null; } if (method === 'desktop.homeScroll') { if (app.id !== 'homescreen' || typeof params?.bottom !== 'boolean') throw Error('PERMISSION_DENIED'); return browser.runtime.sendNativeMessage('vulpes', { type: 'homeScroll', bottom: params.bottom }); } if (method === 'desktop.navigation') { if (app.id !== 'system' || typeof params?.home !== 'boolean') throw Error('PERMISSION_DENIED'); return browser.runtime.sendNativeMessage('vulpes', { type: 'navigation', home: params.home }); } if (method === 'desktop.screen') { if (!app.manifest.permissions?.power) throw Error('PERMISSION_DENIED'); return null; } if (method === 'desktop.wakelock') return null; // Android Activity owns the visible screen. if (method === 'storage.stats') {if(app.id!=='settings')throw Error('PERMISSION_DENIED');return android('storage',{});} if (method === 'media.request') return media(app, params); throw Error('METHOD_NOT_SUPPORTED'); } async function openWeb(url) { const parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol) || parsed.hostname.endsWith('.localhost')) throw Error('URL_SCHEME_DENIED'); return browser.runtime.sendNativeMessage('vulpes', { type: 'open', url: parsed.href }); } async function media(app, p) { const access = app.manifest.permissions?.['device-storage:' + p.type]?.access || ''; if ( !['pictures', 'videos', 'music'].includes(p.type) || !access.includes(['add', 'delete'].includes(p.operation) ? 'write' : 'read') ) throw Error('PERMISSION_DENIED'); const result = await android('media', p); if (['add', 'delete'].includes(p.operation)) broadcast('mediachange', { type: p.type, path: result, reason: p.operation === 'add' ? 'modified' : 'deleted', }); return result; } browser.runtime.onConnect.addListener((port) => { if (port.name !== 'gaia') return; let peer; const attached = ready.then(() => { const url = new URL(port.sender.url); const app = apps.find((a) => a.origin === url.origin); if (!app) throw Error('PERMISSION_DENIED'); peer = { port, app, manager: { documentURI: { spec: url.href } }, sendAsyncMessage: (_, event) => notify(peer, event.type, event.data), edits: new Map(), sendQuery(_, params) { const id=++editSequence; return new Promise((resolve,reject)=>{ const timer=setTimeout(()=>{peer.edits.delete(id);reject(Error('INPUT_CONTEXT_EXPIRED'));},5000); peer.edits.set(id,{resolve,reject,timer}); port.postMessage({edit:{id,params}}); }); }, }; peers.add(peer); }); port.onMessage.addListener(async (message) => { let reply; try { await attached; if(Object.hasOwn(message,'inputFocus')) { if(values['keyboard.gaia.enabled']===true) input.changed(peer,message.inputFocus); return; } if(Object.hasOwn(message,'editReply')) { const edit=peer.edits.get(message.editReply); if(edit) {clearTimeout(edit.timer);peer.edits.delete(message.editReply); if(message.error)edit.reject(Error(message.error));else edit.resolve(message.result);} return; } reply = { result: await handle(peer, message.request) }; } catch (e) { reply = { error: { code: e.message || 'HOST_ERROR' } }; console.warn('VULPES RPC', message.request?.method, e.message); } try { port.postMessage({ id: message.id, reply }); } catch (_) {} }); port.onDisconnect.addListener(() => { attached .then(() => { peers.delete(peer); input.cleanup(peer); for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));} peer.edits.clear(); if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system')) systemReady = false; activities.cleanup(peer); connections.cleanup(peer); alarms.cleanup(peer); }) .catch(() => {}); }); }); let marketplaceDownload=null; browser.runtime.onMessage.addListener(async (message,sender)=>{ if(!message.marketplaceDownload)return; const origin=new URL(sender.url); if(origin.origin!=='https://vulpes-os.org' || !origin.pathname.startsWith('/marketplace/'))throw Error('PERMISSION_DENIED'); await ready;marketplaceDownload=packageURL(message.marketplaceDownload); const app=apps.find(a=>a.id==='marketplace');launch(app); await browser.runtime.sendNativeMessage('vulpes',{type:'marketplace.return'}); return true; });