Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
32
services/call-journal.mjs
Normal file
32
services/call-journal.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// A successful modem snapshot closes missing calls. Transport errors never do.
|
||||
export class CallJournal {
|
||||
constructor(state = {}) {
|
||||
this.pending = state.pending || [];
|
||||
this.completed = state.completed || [];
|
||||
this.nextDate = state.nextDate || 0;
|
||||
}
|
||||
start(call, now = Date.now(), direction) {
|
||||
let item = this.pending.find(c => c.path === call.path);
|
||||
if (!item) {
|
||||
this.nextDate = Math.max(now, this.nextDate + 1);
|
||||
item = {id:String(this.nextDate), path:call.path, date:this.nextDate,
|
||||
number:call.number || '', direction:direction || (['incoming','waiting'].includes(call.state) ? 'incoming':'outgoing'), connectedAt:null};
|
||||
this.pending.push(item);
|
||||
}
|
||||
if (call.number) item.number = call.number;
|
||||
if (['active','held'].includes(call.state) && item.connectedAt === null) item.connectedAt = now;
|
||||
return item;
|
||||
}
|
||||
update(calls, now = Date.now()) {
|
||||
const live = calls.filter(c => c.state !== 'disconnected');
|
||||
for (const call of live) this.start(call, now);
|
||||
const paths = new Set(live.map(c => c.path));
|
||||
for (const item of this.pending.filter(c => !paths.has(c.path))) {
|
||||
this.completed.push({...item, duration:item.connectedAt === null ? 0 : Math.max(0,now-item.connectedAt)});
|
||||
}
|
||||
this.pending = this.pending.filter(c => paths.has(c.path));
|
||||
this.completed = this.completed.slice(-500);
|
||||
}
|
||||
acknowledge(ids) { this.completed = this.completed.filter(c => !ids.includes(c.id)); }
|
||||
snapshot() { return {pending:this.pending, completed:this.completed, nextDate:this.nextDate}; }
|
||||
}
|
||||
56
services/datastores.mjs
Normal file
56
services/datastores.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Shared stores are authorized against the host's installed manifest registry.
|
||||
export class DataStoreService {
|
||||
constructor(apps, storage, notify) {
|
||||
Object.assign(this, { apps, storage, notify });
|
||||
}
|
||||
list(app, name) {
|
||||
return this.apps
|
||||
.filter(
|
||||
(owner) =>
|
||||
Object.hasOwn(owner.manifest['datastores-owned'] || {}, name) &&
|
||||
(owner === app || Object.hasOwn(app.manifest['datastores-access'] || {}, name)),
|
||||
)
|
||||
.map((owner) => ({
|
||||
name,
|
||||
owner: owner.manifestURL,
|
||||
readOnly:
|
||||
owner !== app &&
|
||||
(!!app.manifest['datastores-access'][name].readonly ||
|
||||
app.manifest['datastores-access'][name].access === 'readonly'),
|
||||
}));
|
||||
}
|
||||
async run(app, { name, owner, operation, args = [] }) {
|
||||
const allowed = this.list(app, name).find((store) => store.owner === owner);
|
||||
if (!allowed) throw new Error('DATASTORE_DENIED');
|
||||
const write = ['add', 'put', 'remove', 'clear'].includes(operation);
|
||||
if (write && allowed.readOnly) throw new Error('DATASTORE_READ_ONLY');
|
||||
if (!['get', 'snapshot', 'length', 'add', 'put', 'remove', 'clear'].includes(operation))
|
||||
throw new Error('METHOD_NOT_SUPPORTED');
|
||||
if (!Array.isArray(args) || args.length > 128) throw new Error('INVALID_ARGUMENT');
|
||||
const ids =
|
||||
operation === 'get'
|
||||
? args
|
||||
: operation === 'remove'
|
||||
? [args[0]]
|
||||
: ['add', 'put'].includes(operation) && args[1] !== undefined
|
||||
? [args[1]]
|
||||
: [];
|
||||
for (const id of ids) {
|
||||
if (
|
||||
!(typeof id === 'string' && id.length > 0 && id.length <= 512) &&
|
||||
!(Number.isSafeInteger(id) && id >= 0)
|
||||
)
|
||||
throw new Error('INVALID_KEY');
|
||||
}
|
||||
const result = await this.storage.run(name + '\n' + owner, operation, args);
|
||||
if (write)
|
||||
this.notify({
|
||||
name,
|
||||
owner,
|
||||
revisionId: result.revisionId,
|
||||
operation: operation === 'put' ? 'update' : operation,
|
||||
id: operation === 'remove' ? args[0] : result.value,
|
||||
});
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
39
services/notifications.mjs
Normal file
39
services/notifications.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Gaia owns presentation; this store owns notification identity and lifetime.
|
||||
export function notificationLaunch(app) {
|
||||
const path = app.manifest.messages?.find(message => typeof message.notification === 'string')?.notification ||
|
||||
app.manifest.launch_path || '/index.html';
|
||||
const url = new URL(path, app.origin);
|
||||
if (url.origin !== new URL(app.origin).origin) throw Error('INVALID_NOTIFICATION_TARGET');
|
||||
const entryPoint = Object.entries(app.manifest.entry_points || {}).find(([, entry]) =>
|
||||
entry.launch_path && new URL(entry.launch_path, app.origin).pathname === url.pathname)?.[0];
|
||||
return {manifestURL:app.manifestURL, origin:app.origin, url:url.href, entryPoint};
|
||||
}
|
||||
export class Notifications {
|
||||
constructor(rows = []) {
|
||||
this.rows = new Map(rows.map(row => [row.id, row]));
|
||||
this.sequence = Math.max(Date.now(), ...rows.map(row => Number(row.id) || 0));
|
||||
}
|
||||
list(owner) { return [...this.rows.values()].filter(row => !owner || row.owner === owner); }
|
||||
show(app, options) {
|
||||
const old = options.tag && this.list(app.id).find(row => row.tag === options.tag);
|
||||
const id = old?.id || String(++this.sequence);
|
||||
const icons = app.manifest.icons || {};
|
||||
const icon = options.icon || icons[Math.max(...Object.keys(icons).map(Number))] || '';
|
||||
const row = {id, owner:app.id, manifestURL:app.manifestURL,
|
||||
title:String(options.title || ''), text:String(options.body || ''),
|
||||
body:String(options.body || ''), tag:String(options.tag || ''),
|
||||
data:options.data ?? null, timestamp:Date.now(),
|
||||
icon:icon ? new URL(icon, app.origin).href : '',
|
||||
lang:options.lang || '', dir:options.dir || 'auto',
|
||||
mozbehavior:{noscreen:!!options.silent, nosound:!!options.silent,
|
||||
novibrate:!!options.silent}, type:'desktop-notification'};
|
||||
this.rows.set(id,row);
|
||||
return row;
|
||||
}
|
||||
remove(id, owner) {
|
||||
const row = this.rows.get(String(id));
|
||||
if (!row || (owner && row.owner !== owner)) throw Error('PERMISSION_DENIED');
|
||||
this.rows.delete(row.id);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
54
services/platform.mjs
Normal file
54
services/platform.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Shared authorization for platform adapters. Identity comes from the host, never the request.
|
||||
export function authorizePlatform(app, operation, params = {}) {
|
||||
const permissions = app.manifest.permissions || {};
|
||||
const writeMedia = (type) =>
|
||||
['pictures', 'music', 'videos'].includes(type) &&
|
||||
permissions['device-storage:' + type]?.access?.includes('write');
|
||||
const allowed = {
|
||||
capabilities: true,
|
||||
notificationEvent: app.id === 'system',
|
||||
systemReady: app.id === 'system',
|
||||
power: app.id === 'system',
|
||||
callScreenClosed: app.id === 'callscreen',
|
||||
settings: ['settings', 'system', 'costcontrol'].includes(app.id),
|
||||
snapshot: ['settings', 'system', 'costcontrol'].includes(app.id),
|
||||
wifi: ['settings', 'system'].includes(app.id),
|
||||
notification: !!permissions['desktop-notification'],
|
||||
capture: app.id === 'camera' && writeMedia('pictures'),
|
||||
cameraPreview: app.id === 'camera' && writeMedia('pictures'),
|
||||
pick: writeMedia(params.type),
|
||||
contact: !!permissions.contacts?.access?.includes('write'),
|
||||
dial: app.id === 'communications',
|
||||
calls: ['system', 'communications', 'callscreen'].includes(app.id),
|
||||
callHistory: app.id === 'communications',
|
||||
ackCallHistory: app.id === 'communications',
|
||||
hangup: ['system', 'communications', 'callscreen'].includes(app.id),
|
||||
answer: ['system', 'communications', 'callscreen'].includes(app.id),
|
||||
sms: app.id === 'sms',
|
||||
callAudio: ['system','callscreen'].includes(app.id),
|
||||
alarms: app.id === 'clock',
|
||||
};
|
||||
if (!Object.hasOwn(allowed, operation)) throw Error('METHOD_NOT_SUPPORTED');
|
||||
if (!allowed[operation]) throw Error('PERMISSION_DENIED');
|
||||
if (operation === 'cameraPreview' &&
|
||||
(!['start', 'frame', 'stop', 'focus', 'zoom', 'picture'].includes(params.action) ||
|
||||
(params.action === 'start' && !['back', 'front'].includes(params.camera))))
|
||||
throw Error('INVALID_VALUE');
|
||||
if (operation === 'cameraPreview') {
|
||||
if (params.action === 'zoom' && (!Number.isFinite(params.zoom) || params.zoom<1 || params.zoom>100))
|
||||
throw Error('INVALID_VALUE');
|
||||
if (params.action === 'focus' && params.area != null) {
|
||||
const a=params.area;
|
||||
if (!['left','right','top','bottom'].every(k=>Number.isInteger(a[k]) && Math.abs(a[k])<=1000) ||
|
||||
a.left>=a.right || a.top>=a.bottom) throw Error('INVALID_VALUE');
|
||||
}
|
||||
if (params.action === 'picture' && params.size != null &&
|
||||
!['width','height'].every(k=>Number.isInteger(params.size[k]) && params.size[k]>0 && params.size[k]<=20000))
|
||||
throw Error('INVALID_VALUE');
|
||||
}
|
||||
if (operation === 'ackCallHistory' && (!Array.isArray(params.ids) || params.ids.length > 500 || params.ids.some(id => typeof id !== 'string' || !/^\d{1,20}$/.test(id)))) throw Error('INVALID_VALUE');
|
||||
if (operation === 'wifi' && JSON.stringify(params).length > 4096)
|
||||
throw Error('QuotaExceededError');
|
||||
if (operation === 'notification' && JSON.stringify(params).length > 65536)
|
||||
throw Error('QuotaExceededError');
|
||||
}
|
||||
28
services/screen-power.mjs
Normal file
28
services/screen-power.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Preserve power transitions; coalesce brightness steps while hardware is busy.
|
||||
export class ScreenPower {
|
||||
constructor(state, apply) {
|
||||
this.state = state;
|
||||
this.apply = apply;
|
||||
this.pending = Promise.resolve();
|
||||
this.tail = null;
|
||||
}
|
||||
set(name, value) {
|
||||
if (name === 'screenBrightness' && this.tail?.name === name && !this.tail.started) {
|
||||
this.tail.value = value;
|
||||
return this.tail.promise;
|
||||
}
|
||||
const queued = {name, value, started:false};
|
||||
const operation = this.pending.then(async () => {
|
||||
queued.started = true;
|
||||
if (this.tail === queued) this.tail = null;
|
||||
const next = {...this.state, [name]:queued.value};
|
||||
await this.apply(next, name);
|
||||
Object.assign(this.state, next);
|
||||
return {...this.state};
|
||||
});
|
||||
queued.promise = operation;
|
||||
this.tail = queued;
|
||||
this.pending = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
97
services/settings.mjs
Normal file
97
services/settings.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// No Gecko, XPCOM, DOM or filesystem imports in the service contract.
|
||||
export const API_VERSION = 1;
|
||||
|
||||
export class ServiceError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function key(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value ||
|
||||
value.length > 128 ||
|
||||
['__proto__', 'constructor', 'prototype'].includes(value)
|
||||
) {
|
||||
throw new ServiceError('INVALID_KEY');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// v1 transports JSON only. Binary images will need a separate attachment contract.
|
||||
function jsonValue(value, depth = 0) {
|
||||
if (depth > 20) throw new ServiceError('INVALID_VALUE');
|
||||
if (value === null || typeof value === 'boolean' || typeof value === 'string') return;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => jsonValue(v, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
key(k);
|
||||
jsonValue(v, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new ServiceError('INVALID_VALUE');
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
constructor(storage, { maxBytes = 1024 * 1024 } = {}) {
|
||||
this.storage = storage;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
// Only a trusted host adapter may bind a session. Never take identity or
|
||||
// permissions from an application's RPC payload.
|
||||
bindSession(identity, permissions) {
|
||||
if (typeof identity !== 'string' || !identity) throw new ServiceError('INVALID_IDENTITY');
|
||||
const grants = new Set(permissions);
|
||||
return async (request) => {
|
||||
const id = request && request.id;
|
||||
try {
|
||||
if (!request || request.version !== API_VERSION)
|
||||
throw new ServiceError('API_VERSION_UNSUPPORTED');
|
||||
if (!Number.isSafeInteger(id) || id < 0) throw new ServiceError('INVALID_REQUEST');
|
||||
const { method, params } = request;
|
||||
if (method === 'settings.get') {
|
||||
if (!grants.has('settings.read')) throw new ServiceError('PERMISSION_DENIED');
|
||||
const name = key(params && params.key);
|
||||
const value = await this.storage.get(name);
|
||||
return {
|
||||
version: API_VERSION,
|
||||
id,
|
||||
result: { found: value !== undefined, value: value === undefined ? null : value },
|
||||
};
|
||||
}
|
||||
if (method === 'settings.set') {
|
||||
if (!grants.has('settings.write')) throw new ServiceError('PERMISSION_DENIED');
|
||||
const values = params && params.values;
|
||||
if (!values || Object.getPrototypeOf(values) !== Object.prototype)
|
||||
throw new ServiceError('INVALID_VALUE');
|
||||
const entries = Object.entries(values);
|
||||
if (!entries.length || entries.length > 128) throw new ServiceError('INVALID_VALUE');
|
||||
// Validate the entire request before opening a write transaction.
|
||||
for (const [k, v] of entries) {
|
||||
key(k);
|
||||
jsonValue(v);
|
||||
}
|
||||
if (JSON.stringify(values).length > this.maxBytes)
|
||||
throw new ServiceError('VALUE_TOO_LARGE');
|
||||
await this.storage.setMany(entries);
|
||||
return { version: API_VERSION, id, result: { committed: true } };
|
||||
}
|
||||
throw new ServiceError('METHOD_NOT_SUPPORTED');
|
||||
} catch (error) {
|
||||
return {
|
||||
version: API_VERSION,
|
||||
id,
|
||||
error: { code: error instanceof ServiceError ? error.code : 'STORAGE_ERROR' },
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue