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
121
host/Alarms.sys.mjs
Normal file
121
host/Alarms.sys.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Local alarms survive host restarts. They cannot wake a powered-off PC.
|
||||
export class Alarms {
|
||||
constructor(identity, launch, storage, schedule) {
|
||||
this.storage = storage;
|
||||
this.schedule = schedule;
|
||||
this.identity = identity;
|
||||
this.launch = launch;
|
||||
this.listeners = new Map();
|
||||
this.queue = Promise.resolve();
|
||||
this.lastLaunch = new Map();
|
||||
}
|
||||
async init() {
|
||||
this.state = (await this.storage.load()) || { nextId: 1, records: [], pending: [] };
|
||||
this.timer = this.schedule(() => this.serial(() => this.tick()).catch(console.error), 1000);
|
||||
}
|
||||
serial(fn) {
|
||||
const p = this.queue.then(fn);
|
||||
this.queue = p.catch(() => {});
|
||||
return p;
|
||||
}
|
||||
save() {
|
||||
return this.storage.save(this.state);
|
||||
}
|
||||
cleanup(actor) {
|
||||
this.listeners.delete(actor);
|
||||
}
|
||||
due(record) {
|
||||
return record.respectTimezone === 'ignoreTimezone' ? +new Date(...record.local) : record.date;
|
||||
}
|
||||
request(actor, app, params) {
|
||||
if (!app.manifest.permissions?.alarms) throw new Error('PERMISSION_DENIED');
|
||||
return this.serial(async () => {
|
||||
const { operation, date, respectTimezone, data, id, local } = params;
|
||||
if (operation === 'getAll')
|
||||
return this.state.records
|
||||
.filter((r) => r.owner === app.id)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
date: this.due(r),
|
||||
respectTimezone: r.respectTimezone,
|
||||
data: r.data,
|
||||
}));
|
||||
if (operation === 'add') {
|
||||
if (
|
||||
!Number.isFinite(date) ||
|
||||
!['honorTimezone', 'ignoreTimezone'].includes(respectTimezone) ||
|
||||
!Array.isArray(local) ||
|
||||
local.length !== 7 ||
|
||||
!local.every(Number.isFinite)
|
||||
)
|
||||
throw new Error('InvalidStateError');
|
||||
if (this.state.records.filter((r) => r.owner === app.id).length >= 256)
|
||||
throw new Error('QuotaExceededError');
|
||||
if (JSON.stringify(data).length > 65536) throw new Error('QuotaExceededError');
|
||||
const record = {
|
||||
owner: app.id,
|
||||
id: this.state.nextId++,
|
||||
date,
|
||||
respectTimezone,
|
||||
data,
|
||||
local,
|
||||
};
|
||||
this.state.records.push(record);
|
||||
await this.save();
|
||||
return record.id;
|
||||
}
|
||||
if (operation === 'remove') {
|
||||
this.state.records = this.state.records.filter((r) => r.owner !== app.id || r.id !== id);
|
||||
await this.save();
|
||||
return null;
|
||||
}
|
||||
if (operation === 'subscribe') {
|
||||
const routes =
|
||||
app.manifest.messages
|
||||
?.flatMap((m) => Object.entries(m))
|
||||
.filter(([name]) => name === 'alarm')
|
||||
.map(([, path]) => path) || [];
|
||||
if (
|
||||
!routes.some(
|
||||
(path) => actor.manager.documentURI.spec.split(/[?#]/)[0] === app.origin + path,
|
||||
)
|
||||
)
|
||||
throw new Error('PERMISSION_DENIED');
|
||||
this.listeners.set(actor, app.id);
|
||||
await this.tick();
|
||||
return null;
|
||||
}
|
||||
throw new Error('METHOD_NOT_SUPPORTED');
|
||||
});
|
||||
}
|
||||
async tick() {
|
||||
const ready = this.state.records.filter((r) => this.due(r) <= Date.now());
|
||||
if (ready.length) {
|
||||
this.state.records = this.state.records.filter((r) => !ready.includes(r));
|
||||
this.state.pending.push(...ready.map((r) => ({ ...r, date: this.due(r) })));
|
||||
await this.save();
|
||||
}
|
||||
let changed = false;
|
||||
for (const record of [...this.state.pending]) {
|
||||
const receiver = [...this.listeners].find(
|
||||
([actor, owner]) => owner === record.owner && this.identity(actor)?.id === owner,
|
||||
);
|
||||
if (receiver) {
|
||||
try {
|
||||
receiver[0].sendAsyncMessage('Vulpes:Event', {
|
||||
type: 'alarm',
|
||||
data: { id: record.id, date: record.date, data: record.data },
|
||||
});
|
||||
this.state.pending.splice(this.state.pending.indexOf(record), 1);
|
||||
changed = true;
|
||||
} catch (_) {
|
||||
this.listeners.delete(receiver[0]);
|
||||
}
|
||||
} else if (Date.now() - (this.lastLaunch.get(record.owner) || 0) > 5000) {
|
||||
this.lastLaunch.set(record.owner, Date.now());
|
||||
this.launch(record.owner);
|
||||
}
|
||||
}
|
||||
if (changed) await this.save();
|
||||
}
|
||||
}
|
||||
91
host/Connections.sys.mjs
Normal file
91
host/Connections.sys.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Actor-bound local inter-app channels. No caller-supplied identity or port owner.
|
||||
const routes = {
|
||||
search: ['system', 'search'],
|
||||
'search-results': ['search', 'system'],
|
||||
'app-metrics': ['search', 'system'],
|
||||
mediacomms: ['music', 'system'],
|
||||
};
|
||||
export class Connections {
|
||||
constructor(identity, notify) {
|
||||
Object.assign(this, { identity, notify });
|
||||
this.ports = new Map();
|
||||
this.listeners = new Map();
|
||||
}
|
||||
subscribe(actor) {
|
||||
this.listeners.set(actor, this.identity(actor).id);
|
||||
for (const port of this.ports.values()) this.attach(port);
|
||||
}
|
||||
attach(port) {
|
||||
if (port.receiver || !this.ports.has(port.id)) return;
|
||||
const target = [...this.listeners.keys()].find(
|
||||
(actor) =>
|
||||
this.identity(actor)?.id === port.target &&
|
||||
new URL(actor.manager.documentURI.spec).pathname === port.path,
|
||||
);
|
||||
if (!target) return;
|
||||
port.receiver = target;
|
||||
this.notify(target, 'connection', { keyword: port.keyword, id: port.id, side: 'receiver' });
|
||||
for (const data of port.pending) this.notify(target, 'portmessage', { id: port.id, data });
|
||||
port.pending = [];
|
||||
}
|
||||
connect(actor, keyword, apps) {
|
||||
const route = routes[keyword],
|
||||
app = this.identity(actor);
|
||||
if (
|
||||
!route ||
|
||||
route[0] !== app.id ||
|
||||
!apps.find((a) => a.id === route[1])?.manifest.connections?.[keyword]
|
||||
)
|
||||
throw new Error('CONNECTION_DENIED');
|
||||
if ([...this.ports.values()].filter((p) => p.sender === actor).length >= 32)
|
||||
throw new Error('QUOTA_EXCEEDED');
|
||||
const receiver = apps.find((a) => a.id === route[1]);
|
||||
const path = new URL(
|
||||
receiver.manifest.connections[keyword].handler_path || '/index.html',
|
||||
receiver.origin + '/',
|
||||
).pathname;
|
||||
const port = {
|
||||
id: Services.uuid.generateUUID().toString(),
|
||||
keyword,
|
||||
sender: actor,
|
||||
target: route[1],
|
||||
path,
|
||||
pending: [],
|
||||
};
|
||||
this.ports.set(port.id, port);
|
||||
this.attach(port);
|
||||
return [{ id: port.id, side: 'sender' }];
|
||||
}
|
||||
send(actor, { id, data }) {
|
||||
const port = this.ports.get(id);
|
||||
if (!port || (actor !== port.sender && actor !== port.receiver))
|
||||
throw new Error('CONNECTION_DENIED');
|
||||
if ((JSON.stringify(data)?.length || 0) > 1024 * 1024) throw new Error('VALUE_TOO_LARGE');
|
||||
const target = actor === port.sender ? port.receiver : port.sender;
|
||||
if (target) this.notify(target, 'portmessage', { id, data });
|
||||
else {
|
||||
if (port.pending.length >= 128) throw new Error('QUOTA_EXCEEDED');
|
||||
port.pending.push(data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
close(actor, id) {
|
||||
const port = this.ports.get(id);
|
||||
if (!port || (actor !== port.sender && actor !== port.receiver))
|
||||
throw new Error('CONNECTION_DENIED');
|
||||
this.ports.delete(id);
|
||||
for (const target of [port.sender, port.receiver])
|
||||
if (target && target !== actor) {
|
||||
try {
|
||||
this.notify(target, 'portclose', { id });
|
||||
} catch (_) {
|
||||
/* Peer already destroyed. */
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup(actor) {
|
||||
this.listeners.delete(actor);
|
||||
for (const port of this.ports.values())
|
||||
if (port.sender === actor || port.receiver === actor) this.close(actor, port.id);
|
||||
}
|
||||
}
|
||||
90
host/Contacts.sys.mjs
Normal file
90
host/Contacts.sys.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Local address book for the desktop profile; no invented sample contacts.
|
||||
export class Contacts {
|
||||
constructor(storage, notify) {
|
||||
this.storage = storage;
|
||||
this.notify = notify;
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
async init() {
|
||||
this.data = (await this.storage.run('vulpes-private-contacts', 'get', ['state'])).value || {
|
||||
revision: 0,
|
||||
contacts: [],
|
||||
};
|
||||
}
|
||||
async request(app, { operation, contact, options = {}, id }) {
|
||||
const access = app.manifest.permissions?.contacts?.access || '';
|
||||
const write = ['save', 'remove', 'clear'].includes(operation);
|
||||
if (!(write ? access.includes('write') : access.includes('read')))
|
||||
throw new Error('PERMISSION_DENIED');
|
||||
if (write) {
|
||||
const commit = this.queue.then(async () => {
|
||||
const next = { revision: this.data.revision, contacts: [...this.data.contacts] };
|
||||
let reason;
|
||||
if (operation === 'save') {
|
||||
if (
|
||||
!contact ||
|
||||
typeof contact !== 'object' ||
|
||||
JSON.stringify(contact).length > 1024 * 1024
|
||||
)
|
||||
throw new Error('INVALID_CONTACT');
|
||||
const index = next.contacts.findIndex((c) => c.id === contact.id);
|
||||
const now = new Date().toISOString();
|
||||
contact = {
|
||||
...contact,
|
||||
id: index < 0 ? Services.uuid.generateUUID().toString() : contact.id,
|
||||
published: index < 0 ? now : next.contacts[index].published,
|
||||
updated: now,
|
||||
};
|
||||
id = contact.id;
|
||||
reason = index < 0 ? 'create' : 'update';
|
||||
if (index < 0) next.contacts.push(contact);
|
||||
else next.contacts[index] = contact;
|
||||
} else if (operation === 'remove') {
|
||||
next.contacts = next.contacts.filter((c) => c.id !== id);
|
||||
reason = 'remove';
|
||||
} else {
|
||||
next.contacts = [];
|
||||
reason = 'remove';
|
||||
}
|
||||
next.revision++;
|
||||
await this.storage.run('vulpes-private-contacts', 'put', [next, 'state']);
|
||||
this.data = next;
|
||||
this.notify({ contactID: id, reason });
|
||||
return contact || null;
|
||||
});
|
||||
this.queue = commit.catch(() => {});
|
||||
return commit;
|
||||
}
|
||||
if (operation === 'count') return this.data.contacts.length;
|
||||
if (operation === 'revision') return this.data.revision;
|
||||
if (!['find', 'all'].includes(operation)) throw new Error('METHOD_NOT_SUPPORTED');
|
||||
let result = [...this.data.contacts];
|
||||
if (options.filterValue !== undefined) {
|
||||
const value = String(options.filterValue).toLocaleLowerCase();
|
||||
result = result.filter((c) =>
|
||||
(options.filterBy || ['name']).some((key) => {
|
||||
const values = Array.isArray(c[key]) ? c[key] : [c[key]];
|
||||
return values.some((item) => {
|
||||
const text = String(item?.value ?? item ?? '').toLocaleLowerCase();
|
||||
if (options.filterOp === 'equals') return text === value;
|
||||
if (options.filterOp === 'startsWith') return text.startsWith(value);
|
||||
if (options.filterOp === 'match' || options.filterOp === 'fuzzyMatch')
|
||||
return (
|
||||
value.replace(/\D/g, '').length > 0 &&
|
||||
text.replace(/\D/g, '').endsWith(value.replace(/\D/g, ''))
|
||||
);
|
||||
return text.includes(value);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options.sortBy)
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
String(a[options.sortBy] || '').localeCompare(String(b[options.sortBy] || '')) *
|
||||
(options.sortOrder === 'descending' ? -1 : 1),
|
||||
);
|
||||
if (options.filterLimit > 0) result = result.slice(0, Math.min(options.filterLimit, 10000));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
36
host/Input.sys.mjs
Normal file
36
host/Input.sys.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Only the bundled keyboard can edit the field reported by a content actor.
|
||||
export class Input {
|
||||
constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
|
||||
changed(actor, state) {
|
||||
const app=this.runtime.identity(actor);
|
||||
if (!app || app.id==='keyboard') return;
|
||||
if (!state) { if(this.focus?.actor===actor) this.hide(); return; }
|
||||
this.focus={actor,state:{...state,id:++this.sequence}};
|
||||
this.runtime.broadcast('input-focus',this.focus.state,'system');
|
||||
this.runtime.broadcast('input-focus',this.focus.state,'keyboard');
|
||||
}
|
||||
hide() {
|
||||
this.focus=null;
|
||||
this.runtime.broadcast('input-focus',null,'system');
|
||||
this.runtime.broadcast('input-focus',null,'keyboard');
|
||||
}
|
||||
cleanup(actor) { if(this.focus?.actor===actor) this.hide(); }
|
||||
async request(actor, p) {
|
||||
const app=this.runtime.identity(actor);
|
||||
if (!app || !['keyboard','system'].includes(app.id)) throw Error('PERMISSION_DENIED');
|
||||
if (p.operation==='hide') { this.hide();return null; }
|
||||
if(app.id!=='keyboard') throw Error('PERMISSION_DENIED');
|
||||
if(p.operation==='state') return this.focus?.state || null;
|
||||
if(p.operation==='height') {
|
||||
if(!Number.isFinite(p.height) || p.height<0 || p.height>600) throw Error('INVALID_VALUE');
|
||||
this.runtime.broadcast('input-height',{height:p.height},'system');return null;
|
||||
}
|
||||
if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
|
||||
const context=this.focus;
|
||||
const result=await context.actor.sendQuery('Vulpes:Edit',p);
|
||||
// An application can close or focus another field while the edit is pending.
|
||||
if(this.focus!==context) throw Error('INPUT_CONTEXT_EXPIRED');
|
||||
Object.assign(context.state,result);
|
||||
return context.state;
|
||||
}
|
||||
}
|
||||
137
host/Media.sys.mjs
Normal file
137
host/Media.sys.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Profile-owned desktop media. Never expose arbitrary host paths to Gaia.
|
||||
Cu.importGlobalProperties(['File']);
|
||||
const TYPES = { pictures: ['image/'], music: ['audio/'], videos: ['video/'] };
|
||||
const MIME = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
flac: 'audio/flac',
|
||||
m4a: 'audio/mp4',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
'3gp': 'video/3gpp',
|
||||
};
|
||||
export class Media {
|
||||
constructor(notify) {
|
||||
this.notify = notify;
|
||||
this.root = PathUtils.join(PathUtils.profileDir, 'vulpes-media');
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
path(type, name = '') {
|
||||
if (typeof name !== 'string') throw new Error('InvalidModificationError');
|
||||
name = name.replace(/^\/sdcard\//, '');
|
||||
const parts = name ? name.split('/') : [];
|
||||
if (
|
||||
parts.some((p) => !p || p === '.' || p === '..' || /[\\\x00-\x1f]/.test(p)) ||
|
||||
name.startsWith('/') ||
|
||||
name.length > 512
|
||||
)
|
||||
throw new Error('SecurityError');
|
||||
const root = PathUtils.join(this.root, type),
|
||||
path = PathUtils.join(root, ...parts);
|
||||
// Reject symlinks, including parent directories, on every access.
|
||||
let current = this.root;
|
||||
for (const part of ['', type, ...parts]) {
|
||||
if (part) current = PathUtils.join(current, part);
|
||||
const f = Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);
|
||||
f.initWithPath(current);
|
||||
if (f.exists() && f.isSymlink()) throw new Error('SecurityError');
|
||||
}
|
||||
return { root, path, name };
|
||||
}
|
||||
async entries(type, directory = '') {
|
||||
const base = this.path(type, directory);
|
||||
if (!(await IOUtils.exists(base.path))) return [];
|
||||
const files = [];
|
||||
const walk = async (path) => {
|
||||
for (const child of await IOUtils.getChildren(path)) {
|
||||
const name = child.slice(base.root.length + 1);
|
||||
this.path(type, name);
|
||||
const info = await IOUtils.stat(child);
|
||||
if (info.type === 'directory') await walk(child);
|
||||
else if (info.type === 'regular')
|
||||
files.push({
|
||||
name: '/sdcard/' + name,
|
||||
path: child,
|
||||
size: info.size,
|
||||
lastModified: info.lastModified,
|
||||
type: MIME[name.split('.').pop().toLowerCase()] || 'application/octet-stream',
|
||||
});
|
||||
}
|
||||
};
|
||||
await walk(base.path);
|
||||
return files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
async request(app, params) {
|
||||
const { type, operation } = params;
|
||||
const access = app.manifest.permissions?.['device-storage:' + type]?.access || '';
|
||||
if (!TYPES[type] || !access.includes(['add', 'delete'].includes(operation) ? 'write' : 'read'))
|
||||
throw new Error('PERMISSION_DENIED');
|
||||
if (['add', 'delete'].includes(operation)) {
|
||||
const result = this.queue.then(() => this.run(type, params));
|
||||
this.queue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
return this.run(type, params);
|
||||
}
|
||||
async run(type, { operation, name = '', blob, since }) {
|
||||
const base = this.path(type, name);
|
||||
await IOUtils.makeDirectory(base.root, { ignoreExisting: true });
|
||||
if (operation === 'available') return 'available';
|
||||
if (operation === 'freeSpace') {
|
||||
const f = Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);
|
||||
f.initWithPath(base.root);
|
||||
return f.diskSpaceAvailable;
|
||||
}
|
||||
if (operation === 'usedSpace')
|
||||
return (await this.entries(type)).reduce((sum, f) => sum + f.size, 0);
|
||||
if (operation === 'enumerate')
|
||||
return (await this.entries(type, name))
|
||||
.filter((f) => !since || f.lastModified >= since)
|
||||
.map(({ path, ...file }) => file);
|
||||
if (operation === 'get') {
|
||||
if (!name || !(await IOUtils.exists(base.path))) throw new Error('NotFoundError');
|
||||
const stat = await IOUtils.stat(base.path);
|
||||
if (stat.type !== 'regular') throw new Error('NotFoundError');
|
||||
return {
|
||||
blob: await File.createFromFileName(base.path),
|
||||
name: '/sdcard/' + base.name,
|
||||
lastModified: stat.lastModified,
|
||||
type: MIME[base.name.split('.').pop().toLowerCase()] || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
if (operation === 'add') {
|
||||
if (!name || !blob || typeof blob.arrayBuffer !== 'function' || blob.size > 512 * 1024 * 1024)
|
||||
throw new Error('InvalidModificationError');
|
||||
const mime = blob.type || MIME[base.name.split('.').pop().toLowerCase()] || '';
|
||||
if (!TYPES[type].some((prefix) => mime.startsWith(prefix)))
|
||||
throw new Error('TypeMismatchError');
|
||||
if (await IOUtils.exists(base.path)) throw new Error('NoModificationAllowedError');
|
||||
await IOUtils.makeDirectory(PathUtils.parent(base.path), { ignoreExisting: true });
|
||||
await IOUtils.write(base.path, new Uint8Array(await blob.arrayBuffer()), {
|
||||
noOverwrite: true,
|
||||
});
|
||||
this.notify({ type, path: '/sdcard/' + base.name, reason: 'created' });
|
||||
this.notify({ type, path: '/sdcard/' + base.name, reason: 'modified' });
|
||||
return '/sdcard/' + base.name;
|
||||
}
|
||||
if (operation === 'delete') {
|
||||
if (!name) throw new Error('SecurityError');
|
||||
if (await IOUtils.exists(base.path)) {
|
||||
if ((await IOUtils.stat(base.path)).type !== 'regular') throw new Error('SecurityError');
|
||||
await IOUtils.remove(base.path);
|
||||
this.notify({ type, path: '/sdcard/' + base.name, reason: 'deleted' });
|
||||
}
|
||||
return '/sdcard/' + base.name;
|
||||
}
|
||||
throw new Error('METHOD_NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
187
host/Messages.sys.mjs
Normal file
187
host/Messages.sys.mjs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// Desktop message store. Radio operations fail explicitly; no synthetic SMS.
|
||||
export class Messages {
|
||||
constructor(storage, radio = null, notify = () => {}, radioStatus = null) {
|
||||
this.storage = storage;
|
||||
this.radio = radio;
|
||||
this.notify = notify;
|
||||
this.radioStatus = radioStatus;
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
receive(incoming) {
|
||||
const work = this.queue.then(async()=>{
|
||||
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
|
||||
const existing=records.find(m=>m.receipt===incoming.receipt);
|
||||
if (existing) return existing;
|
||||
const id=Math.max(Date.now(),...records.map(m=>Number(m.id)+1 || 0));
|
||||
const record={id,threadId:records.find(m=>(m.sender||m.receiver)===incoming.sender)?.threadId || id+1,
|
||||
receipt:incoming.receipt,type:'sms',sender:incoming.sender,receiver:null,
|
||||
body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false};
|
||||
records.push(record);
|
||||
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
||||
return record;
|
||||
});
|
||||
this.queue=work.catch(()=>{});
|
||||
return work;
|
||||
}
|
||||
refreshOutgoing() {
|
||||
if (!this.radioStatus) return Promise.resolve();
|
||||
const work=this.queue.then(async()=>{
|
||||
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
|
||||
for (const message of records.filter(m=>m.delivery==='sending' && m.radioPath)) {
|
||||
const result=await this.radioStatus(message.radioPath);
|
||||
if (!['sent','failed'].includes(result.state)) continue;
|
||||
message.delivery=result.state==='sent'?'sent':'error';
|
||||
message.deliveryStatus=result.state==='sent'?'not-applicable':'error';
|
||||
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
||||
this.notify(result.state,structuredClone(message));
|
||||
}
|
||||
});
|
||||
this.queue=work.catch(()=>{});
|
||||
return work;
|
||||
}
|
||||
request(app, params) {
|
||||
const work=this.queue.then(()=>this.perform(app,params));
|
||||
this.queue=work.catch(()=>{});
|
||||
return work;
|
||||
}
|
||||
async perform(app, { operation, args = [] }) {
|
||||
if (!app.manifest.permissions?.sms) throw new Error('PERMISSION_DENIED');
|
||||
const records =
|
||||
(await this.storage.run('vulpes-private-messages', 'get', ['records'])).value || [];
|
||||
// Gaia's Threads registry coerces identifiers to numbers. Older adapter
|
||||
// records used modem paths/phone numbers and became NaN in that registry.
|
||||
let nextId=records.reduce((n,m)=>Math.max(n,Number.isSafeInteger(m.id)?m.id+1:0),Date.now());
|
||||
const migratedThreads=new Map();
|
||||
let migrated=false;
|
||||
for(const message of records) {
|
||||
if(!Number.isSafeInteger(message.id)) {message.id=nextId++;migrated=true;}
|
||||
if(!Number.isSafeInteger(message.threadId)) {
|
||||
const old=message.threadId;
|
||||
if(!migratedThreads.has(old)) migratedThreads.set(old,nextId++);
|
||||
message.threadId=migratedThreads.get(old);migrated=true;
|
||||
}
|
||||
}
|
||||
if(migrated) await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
||||
if (operation === 'send' && this.radio) {
|
||||
const recipients = Array.isArray(args[0]) ? args[0] : [args[0]];
|
||||
if (recipients.length !== 1) throw new Error('MULTIPLE_RECIPIENTS_UNSUPPORTED');
|
||||
const recipient = recipients[0];
|
||||
const body = args[1];
|
||||
const timestamp = Date.now();
|
||||
const record = {
|
||||
id: nextId++,
|
||||
threadId: records.find(m=>(m.receiver || m.sender)===recipient)?.threadId || nextId++,
|
||||
type: 'sms',
|
||||
sender: null,
|
||||
receiver: recipient,
|
||||
body,
|
||||
timestamp,
|
||||
delivery: 'sending',
|
||||
deliveryStatus: 'pending',
|
||||
read: true,
|
||||
};
|
||||
records.push(record);
|
||||
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
||||
this.notify('sending', structuredClone(record));
|
||||
try {
|
||||
const submitted = await this.radio({ number: recipient, body });
|
||||
if (submitted?.state === 'pending') {
|
||||
record.radioPath = submitted.path;
|
||||
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
||||
return record;
|
||||
}
|
||||
record.delivery = 'sent';
|
||||
record.deliveryStatus = 'not-applicable';
|
||||
} catch (error) {
|
||||
record.delivery = 'error';
|
||||
record.deliveryStatus = 'error';
|
||||
record.error = error.message;
|
||||
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
||||
this.notify('failed', structuredClone(record));
|
||||
throw error;
|
||||
}
|
||||
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
||||
this.notify('sent', structuredClone(record));
|
||||
return record;
|
||||
}
|
||||
if (['send', 'sendMMS', 'retrieveMMS', 'getSmscAddress', 'setSmscAddress'].includes(operation))
|
||||
throw new Error('RadioDisabledError');
|
||||
if (operation === 'getThreads') {
|
||||
const threads = new Map();
|
||||
for (const message of records) {
|
||||
const previous = threads.get(message.threadId);
|
||||
if (!previous || message.timestamp > previous.timestamp)
|
||||
threads.set(message.threadId, {
|
||||
id: message.threadId,
|
||||
body: message.body,
|
||||
timestamp: message.timestamp,
|
||||
participants: [message.sender || message.receiver],
|
||||
unreadCount: records.filter((m) => m.threadId === message.threadId && !m.read).length,
|
||||
lastMessageType: message.type,
|
||||
});
|
||||
}
|
||||
return [...threads.values()].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
if (operation === 'getMessages')
|
||||
return records
|
||||
.filter((m) => {
|
||||
const filter = args[0] || {};
|
||||
return (filter.threadId === undefined || m.threadId === filter.threadId) &&
|
||||
(filter.read === undefined || !!m.read === filter.read) &&
|
||||
(filter.delivery === undefined || m.delivery === filter.delivery) &&
|
||||
(filter.startDate == null || m.timestamp >= new Date(filter.startDate).getTime()) &&
|
||||
(filter.endDate == null || m.timestamp <= new Date(filter.endDate).getTime()) &&
|
||||
(!filter.numbers?.length || filter.numbers.some(number =>
|
||||
number === m.sender || number === m.receiver || m.receivers?.includes(number)));
|
||||
})
|
||||
.sort((a, b) => (a.timestamp - b.timestamp) * (args[1] ? -1 : 1));
|
||||
if (operation === 'getMessage') {
|
||||
const message = records.find((m) => m.id === args[0]);
|
||||
if (!message) throw new Error('NotFoundError');
|
||||
return message;
|
||||
}
|
||||
if (operation === 'getSegmentInfoForText') {
|
||||
const text = String(args[0]);
|
||||
const basic =
|
||||
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
||||
const extra = '\f^{}\\[~]|€';
|
||||
let length = 0,
|
||||
unicode = false;
|
||||
for (const c of text) {
|
||||
if (basic.includes(c)) length++;
|
||||
else if (extra.includes(c)) length += 2;
|
||||
else {
|
||||
unicode = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (unicode) length = text.length;
|
||||
const single = unicode ? 70 : 160,
|
||||
multiple = unicode ? 67 : 153;
|
||||
const charsPerSegment = length > single ? multiple : single,
|
||||
segments = Math.max(1, Math.ceil(length / charsPerSegment));
|
||||
return {
|
||||
segments,
|
||||
charsPerSegment,
|
||||
charsAvailableInLastSegment: segments * charsPerSegment - length,
|
||||
};
|
||||
}
|
||||
if (operation === 'delete') {
|
||||
const ids = Array.isArray(args[0]) ? args[0] : [args[0]],
|
||||
found = ids.map((id) => records.some((m) => m.id === id));
|
||||
await this.storage.run('vulpes-private-messages', 'put', [
|
||||
records.filter((m) => !ids.includes(m.id)),
|
||||
'records',
|
||||
]);
|
||||
return Array.isArray(args[0]) ? found : found[0];
|
||||
}
|
||||
if (operation === 'markMessageRead') {
|
||||
const m = records.find((m) => m.id === args[0]);
|
||||
if (!m) throw new Error('NotFoundError');
|
||||
m.read = !!args[1];
|
||||
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
||||
return m.read;
|
||||
}
|
||||
throw new Error('METHOD_NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
97
host/NativeCamera.sys.mjs
Normal file
97
host/NativeCamera.sys.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Only Platform's authorized camera application may own this worker.
|
||||
import {Subprocess} from 'resource://gre/modules/Subprocess.sys.mjs';
|
||||
import {setTimeout, clearTimeout} from 'resource://gre/modules/Timer.sys.mjs';
|
||||
|
||||
export class NativeCamera {
|
||||
constructor() { this.session = null; this.queue = Promise.resolve(); this.closing = Promise.resolve(); this.nextSession = 0; }
|
||||
|
||||
request(actor, params) {
|
||||
const work = this.queue.then(() => this.perform(actor, params));
|
||||
this.queue = work.catch(() => {});
|
||||
return work;
|
||||
}
|
||||
|
||||
async packet(session, metadata = false) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
(async () => {
|
||||
const pipe=session.process.stdout;
|
||||
const sequence=metadata ? null : await pipe.readUint32();
|
||||
const length=await pipe.readUint32();
|
||||
if(metadata) {
|
||||
if(length>65536) throw Error('CAMERA_INVALID_FORMAT');
|
||||
return pipe.readJSON(length);
|
||||
}
|
||||
if(length!==0 && length!==640*480*3/2) throw Error('CAMERA_INVALID_FRAME');
|
||||
return {sequence,data:length ? await pipe.read(length) : null};
|
||||
})(),
|
||||
new Promise((_, reject) => { timer=setTimeout(()=>reject(Error('CAMERA_TIMEOUT')),15000); }),
|
||||
]);
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
async perform(actor, params) {
|
||||
if (params.action === 'stop') {
|
||||
if (this.session?.actor === actor && this.session.id === params.session) await this.close();
|
||||
return null;
|
||||
}
|
||||
if (params.action === 'start') {
|
||||
if (this.session && this.session.actor !== actor) throw Error('CAMERA_BUSY');
|
||||
await this.close();
|
||||
const process = await Subprocess.call({command:'/usr/bin/python3',
|
||||
arguments:['-u', '/etc/tundra/camera-preview.py', '--camera', params.camera === 'front' ? '1' : '0'],
|
||||
stderr:'ignore'});
|
||||
this.session = {actor, process, id: ++this.nextSession};
|
||||
try {
|
||||
const metadata = await this.packet(this.session, true);
|
||||
if (!metadata.ready || metadata.format !== 'nv21' || metadata.width !== 640 || metadata.height !== 480)
|
||||
throw Error('CAMERA_INVALID_FORMAT');
|
||||
return {...metadata, session: this.session.id};
|
||||
} catch (error) { this.close(); throw error; }
|
||||
}
|
||||
if (!this.session || this.session.actor !== actor || this.session.id !== params.session)
|
||||
throw Error('CAMERA_NOT_OPEN');
|
||||
if (!['frame','focus','zoom','picture'].includes(params.action)) throw Error('CAMERA_INVALID_COMMAND');
|
||||
let controlRejected = false;
|
||||
try {
|
||||
if (params.action === 'frame') {
|
||||
await this.session.process.stdin.write('frame\n');
|
||||
return await this.packet(this.session);
|
||||
}
|
||||
const command = JSON.stringify(params);
|
||||
if (command.length>2048) throw Error('CAMERA_INVALID_COMMAND');
|
||||
await this.session.process.stdin.write(command+'\n');
|
||||
const reply = await this.packet(this.session, true);
|
||||
if (!reply.ok) {
|
||||
controlRejected = true;
|
||||
throw Error(reply.error || 'CAMERA_CONTROL_FAILED');
|
||||
}
|
||||
if (params.action === 'picture') {
|
||||
if (!Number.isInteger(reply.jpegLength) || reply.jpegLength<4 || reply.jpegLength>32*1024*1024)
|
||||
throw Error('CAMERA_INVALID_JPEG');
|
||||
return {data:await this.session.process.stdout.read(reply.jpegLength)};
|
||||
}
|
||||
return reply.result;
|
||||
} catch (error) {
|
||||
// A complete error reply leaves the protocol synchronized. A focus
|
||||
// timeout must not discard a camera whose preview is still running.
|
||||
if (!controlRejected) this.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
const session = this.session;
|
||||
this.session = null;
|
||||
if (!session) return this.closing;
|
||||
session.process.stdin.close().catch(() => {});
|
||||
const timer = setTimeout(() => session.process.kill(), 1500);
|
||||
this.closing = session.process.wait().finally(() => clearTimeout(timer));
|
||||
return this.closing;
|
||||
}
|
||||
|
||||
cleanup(actor) {
|
||||
if (this.session?.actor === actor) this.close();
|
||||
}
|
||||
}
|
||||
496
host/Platform.sys.mjs
Normal file
496
host/Platform.sys.mjs
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { authorizePlatform } from 'resource://vulpes/services/platform.mjs';
|
||||
import {
|
||||
tundraCapabilities,
|
||||
tundraCallAudio,
|
||||
tundraHardware,
|
||||
tundraRadio,
|
||||
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();
|
||||
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);}
|
||||
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.audioInputDevices?.length ||
|
||||
!request.videoInputDevices?.length
|
||||
) {
|
||||
deny();
|
||||
return;
|
||||
}
|
||||
const principal = global.documentPrincipal;
|
||||
let permission = Services.perms.testExactPermissionFromPrincipal(principal, 'camera');
|
||||
if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
|
||||
const allowed = Services.prompt.confirm(
|
||||
browser.ownerDocument.defaultView,
|
||||
'Vulpes — Photo',
|
||||
'Autoriser Photo à utiliser la webcam ? / Allow Camera to use your webcam?',
|
||||
);
|
||||
permission = allowed
|
||||
? Ci.nsIPermissionManager.ALLOW_ACTION
|
||||
: Ci.nsIPermissionManager.DENY_ACTION;
|
||||
Services.perms.addFromPrincipal(principal, 'camera', permission);
|
||||
}
|
||||
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) {
|
||||
deny();
|
||||
return;
|
||||
}
|
||||
if (!(await actor.checkOSPermission(true, false, false))) {
|
||||
deny();
|
||||
return;
|
||||
}
|
||||
// Gecko consumes this one-shot grant when allocating the real device.
|
||||
// Use its top-level principal, as Firefox's WebRTC permission handler does.
|
||||
const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal;
|
||||
Services.perms.addFromPrincipal(
|
||||
topPrincipal,
|
||||
'MediaManagerVideo',
|
||||
Ci.nsIPermissionManager.ALLOW_ACTION,
|
||||
Ci.nsIPermissionManager.EXPIRE_SESSION,
|
||||
);
|
||||
const device = request.videoInputDevices[0];
|
||||
actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
|
||||
actor.sendAsyncMessage('webrtc:Allow', {
|
||||
callID: request.callID,
|
||||
windowID: request.windowID,
|
||||
devices: [request.videoInputDevices[0].deviceIndex],
|
||||
});
|
||||
}
|
||||
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 (['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;
|
||||
}
|
||||
}
|
||||
494
host/Runtime.sys.mjs
Normal file
494
host/Runtime.sys.mjs
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
import { setInterval } 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 { 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 } 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.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'));
|
||||
// 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.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;
|
||||
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;
|
||||
},
|
||||
});
|
||||
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}),
|
||||
);
|
||||
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==='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 (method === 'activities.start') {
|
||||
if (params?.name === 'record' && params.data?.type === 'photos') {
|
||||
// Gallery's Photo button opens the shared Gaia UI; it expects no result file.
|
||||
if (app.id === 'gallery') {
|
||||
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 === 'settings.snapshot')
|
||||
return this.grants(app).includes('settings.read')
|
||||
? { result: { ...this.values } }
|
||||
: { error: { code: 'PERMISSION_DENIED' } };
|
||||
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.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' && !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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
178
host/Tundra.sys.mjs
Normal file
178
host/Tundra.sys.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// Native state is published by Tundra, never supplied by an application.
|
||||
export async function tundraSnapshot() {
|
||||
try {
|
||||
const value = await IOUtils.readJSON('/run/tundra-hardware/status.json');
|
||||
const age = Date.now() / 1000 - value.recordedAt;
|
||||
if (value.schema !== 1 || !Number.isFinite(age) || age < -5 || age > 20)
|
||||
throw Error('STALE_HARDWARE_STATE');
|
||||
const battery = value.battery;
|
||||
return {
|
||||
battery:
|
||||
battery &&
|
||||
Number.isFinite(battery.level) &&
|
||||
battery.level >= 0 &&
|
||||
battery.level <= 1 &&
|
||||
typeof battery.charging === 'boolean'
|
||||
? battery
|
||||
: null,
|
||||
wifi: value.wifi,
|
||||
recordedAt: value.recordedAt,
|
||||
available: true,
|
||||
};
|
||||
} catch (_) {
|
||||
return { battery: null, wifi: { available: false, state: 'unavailable' }, available: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function tundraCapabilities(release) {
|
||||
const radio = Services.env?.get('VULPES_TUNDRA_RADIO') === '1';
|
||||
return {
|
||||
platform: 'tundra',
|
||||
platformName: 'Tundra',
|
||||
model: 'Google Pixel 3a',
|
||||
tundra: '0.1.0-dev',
|
||||
adapter: { version: '0.1.0-dev', device: 'sargo' },
|
||||
version: release.version,
|
||||
gaia: release.gaia,
|
||||
gecko: Services.appinfo.platformVersion,
|
||||
camera: true,
|
||||
media: true,
|
||||
contacts: true,
|
||||
notifications: true,
|
||||
dial: radio,
|
||||
callControl: radio,
|
||||
sms: radio,
|
||||
alarms: false,
|
||||
hardwareStatus: true,
|
||||
wifiControl: Services.env?.get('VULPES_TUNDRA_WIFI') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
export async function tundraRadio(operation, params = {}) {
|
||||
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
|
||||
if (!['status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
|
||||
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
|
||||
const process = await Subprocess.call({
|
||||
command: '/usr/bin/python3',
|
||||
arguments: ['/etc/tundra/radio-control.py', '--client-envelope'],
|
||||
stderr: 'ignore',
|
||||
});
|
||||
await process.stdin.write(JSON.stringify({ version: 1, operation, ...params }));
|
||||
await process.stdin.close();
|
||||
let output = '';
|
||||
for (;;) {
|
||||
const chunk = await process.stdout.readString();
|
||||
if (!chunk) break;
|
||||
output += chunk;
|
||||
if (output.length > 65536) {
|
||||
process.kill();
|
||||
throw Error('RADIO_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
const { exitCode } = await process.wait();
|
||||
if (exitCode) throw Error('RADIO_UNAVAILABLE');
|
||||
let reply;
|
||||
try {
|
||||
reply = JSON.parse(output);
|
||||
} catch (_) {
|
||||
throw Error('RADIO_INVALID_REPLY');
|
||||
}
|
||||
if (reply?.ok === false) {
|
||||
const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY',
|
||||
'RADIO_REQUEST_FAILED', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
|
||||
throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
|
||||
}
|
||||
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
|
||||
return reply.result;
|
||||
}
|
||||
|
||||
let wifiScanPending = null;
|
||||
let wifiScanCache = null;
|
||||
const WIFI_SCAN_CACHE_MS = 5000;
|
||||
|
||||
export async function tundraWifi(params) {
|
||||
if (Services.env.get('VULPES_TUNDRA_WIFI') !== '1') throw Error('NOT_SUPPORTED');
|
||||
if (!['scan', 'connect', 'disconnect', 'status', 'setEnabled', 'known', 'forget'].includes(params.operation))
|
||||
throw Error('WIFI_INVALID_REQUEST');
|
||||
if (params.operation !== 'scan') {
|
||||
try { return await wifiCommand(params); }
|
||||
finally { wifiScanCache = null; }
|
||||
}
|
||||
// System and Settings share a broker: at most one Python scan client exists.
|
||||
if (wifiScanPending) return wifiScanPending;
|
||||
if (wifiScanCache && Date.now() < wifiScanCache.expires) {
|
||||
if (wifiScanCache.error) throw wifiScanCache.error;
|
||||
return wifiScanCache.result;
|
||||
}
|
||||
wifiScanPending = wifiCommand(params).then(result => {
|
||||
wifiScanCache = {result, expires:Date.now() + WIFI_SCAN_CACHE_MS};
|
||||
return result;
|
||||
}, error => {
|
||||
// A missing interface must not turn retries into another process storm.
|
||||
wifiScanCache = {error, expires:Date.now() + WIFI_SCAN_CACHE_MS};
|
||||
throw error;
|
||||
}).finally(() => { wifiScanPending = null; });
|
||||
return wifiScanPending;
|
||||
}
|
||||
|
||||
async function wifiCommand(params) {
|
||||
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
|
||||
const process = await Subprocess.call({
|
||||
command: '/usr/bin/python3',
|
||||
arguments: ['/etc/tundra/wifi-control.py', '--client'],
|
||||
stderr: 'ignore',
|
||||
});
|
||||
// Passwords are never command-line arguments, preferences or log messages.
|
||||
await process.stdin.write(JSON.stringify(params));
|
||||
await process.stdin.close();
|
||||
let output = '';
|
||||
for (;;) {
|
||||
const chunk = await process.stdout.readString();
|
||||
if (!chunk) break;
|
||||
output += chunk;
|
||||
if (output.length > 65536) {
|
||||
process.kill();
|
||||
throw Error('WIFI_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
const { exitCode } = await process.wait();
|
||||
if (exitCode) throw Error('WIFI_UNAVAILABLE');
|
||||
const reply = JSON.parse(output);
|
||||
if (reply.error) throw Error(reply.error);
|
||||
return reply.result;
|
||||
}
|
||||
|
||||
export async function tundraHardware(params) {
|
||||
if (Services.env.get('VULPES_TUNDRA') !== '1') return;
|
||||
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
|
||||
const process = await Subprocess.call({command:'/usr/bin/python3',
|
||||
arguments:['/etc/tundra/buttons.py', '--client'], stderr:'ignore'});
|
||||
await process.stdin.write(JSON.stringify(params));
|
||||
await process.stdin.close();
|
||||
const output = await process.stdout.readString();
|
||||
const {exitCode} = await process.wait();
|
||||
if (exitCode || !JSON.parse(output)?.ok) throw Error('HARDWARE_REQUEST_FAILED');
|
||||
}
|
||||
|
||||
export async function tundraCallAudio(params) {
|
||||
const {Subprocess} = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
|
||||
const process = await Subprocess.call({command:'/usr/bin/python3',
|
||||
arguments:['/etc/tundra/call-audio.py','--client'],stderr:'ignore'});
|
||||
await process.stdin.write(JSON.stringify(params));
|
||||
await process.stdin.close();
|
||||
let text = '';
|
||||
for (;;) {
|
||||
const chunk = await process.stdout.readString();
|
||||
if (!chunk) break;
|
||||
text += chunk;
|
||||
if (text.length > 4096) {
|
||||
process.kill();
|
||||
throw Error('CALL_AUDIO_INVALID_REPLY');
|
||||
}
|
||||
}
|
||||
const {exitCode} = await process.wait();
|
||||
if (exitCode) throw Error('CALL_AUDIO_UNAVAILABLE');
|
||||
const reply = JSON.parse(text);
|
||||
if (reply.error) throw Error(reply.error);
|
||||
return reply.result;
|
||||
}
|
||||
198
host/Views.sys.mjs
Normal file
198
host/Views.sys.mjs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// The desktop-only adapter for external web pages. A web page is loaded as a
|
||||
// real top-level browser, never by removing CSP or frame-ancestors protection.
|
||||
export const Views = {
|
||||
entries: new Map(),
|
||||
remove(id, entry) {
|
||||
const contextId = entry.topContextId;
|
||||
try {
|
||||
entry.browser.removeProgressListener(entry.listener);
|
||||
} catch (_) {} // Teardown may have detached its loader.
|
||||
entry.browser.remove();
|
||||
this.entries.delete(id);
|
||||
Services.ppmm.sharedData.set(
|
||||
'vulpes:host-contexts',
|
||||
(Services.ppmm.sharedData.get('vulpes:host-contexts') || []).filter((id) => id !== contextId),
|
||||
);
|
||||
Services.ppmm.sharedData.flush();
|
||||
},
|
||||
prune() {
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (!BrowsingContext.get(id)?.parent) {
|
||||
this.remove(id, entry);
|
||||
continue;
|
||||
}
|
||||
// A detached iframe's BrowsingContext can outlive its DOM element.
|
||||
// Ask the trusted child actor about actual ownership, not just the BC tree.
|
||||
if (entry.checking) continue;
|
||||
entry.checking = true;
|
||||
entry.actor
|
||||
.sendQuery('Vulpes:ViewAlive', { contextId: id })
|
||||
.then(
|
||||
(alive) => {
|
||||
if (!alive && this.entries.get(id) === entry) this.remove(id, entry);
|
||||
},
|
||||
() => {
|
||||
if (this.entries.get(id) === entry) this.remove(id, entry);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
entry.checking = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
cleanup(actor) {
|
||||
for (const [id, entry] of this.entries)
|
||||
if (entry.actor === actor) {
|
||||
this.remove(id, entry);
|
||||
}
|
||||
},
|
||||
async request(actor, { contextId, operation, url, geometry }) {
|
||||
const context = BrowsingContext.get(contextId);
|
||||
if (!context || context.parent !== actor.browsingContext) throw new Error('INVALID_VIEW_OWNER');
|
||||
let entry = this.entries.get(contextId);
|
||||
if (entry && entry.actor !== actor) throw new Error('INVALID_VIEW_OWNER');
|
||||
if (operation === 'cancelTouch') {
|
||||
const target = entry ? entry.browser.browsingContext : context;
|
||||
return target.currentWindowGlobal
|
||||
.getActor('VulpesTouch')
|
||||
.sendQuery('Vulpes:CancelTouch', [
|
||||
{ identifier: 0, offsetX: 0, offsetY: 0, radiiX: 1, radiiY: 1 },
|
||||
]);
|
||||
}
|
||||
if (operation === 'capture') {
|
||||
const target = entry ? entry.browser.browsingContext : context;
|
||||
const image = await target.currentWindowGlobal.drawSnapshot(null, 1, 'white');
|
||||
const doc = actor.browsingContext.top.embedderElement.ownerDocument;
|
||||
const canvas = doc.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
canvas.getContext('2d').drawImage(image, 0, 0);
|
||||
image.close();
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
}
|
||||
if (operation === 'open') {
|
||||
const uri = Services.io.newURI(url);
|
||||
if (!['http', 'https'].includes(uri.scheme)) throw new Error('URL_SCHEME_DENIED');
|
||||
if (!entry) {
|
||||
const shell = actor.browsingContext.top.embedderElement.ownerDocument.defaultView;
|
||||
const browser = shell.document.createXULElement('browser');
|
||||
browser.setAttribute('type', 'content');
|
||||
browser.setAttribute('remote', 'true');
|
||||
browser.setAttribute('remoteType', 'web');
|
||||
browser.setAttribute('maychangeremoteness', 'true');
|
||||
browser.style.cssText =
|
||||
'display:block;position:absolute;background:white;visibility:hidden';
|
||||
shell.document.getElementById('screen').append(browser);
|
||||
if (!Services.ppmm.sharedData.get('vulpes:native-touch')) {
|
||||
browser.browsingContext.inRDMPane = true;
|
||||
browser.browsingContext.setRDMPaneMaxTouchPoints(1);
|
||||
browser.browsingContext.touchEventsOverride = 'enabled';
|
||||
}
|
||||
const contexts = Services.ppmm.sharedData.get('vulpes:host-contexts') || [];
|
||||
Services.ppmm.sharedData.set(
|
||||
'vulpes:host-contexts',
|
||||
contexts.concat(browser.browsingContext.id),
|
||||
);
|
||||
Services.ppmm.sharedData.flush();
|
||||
const emit = (type, detail = {}) => {
|
||||
try {
|
||||
actor.sendAsyncMessage('Vulpes:Event', {
|
||||
type: 'view',
|
||||
data: { contextId, type, detail },
|
||||
});
|
||||
} catch (_) {}
|
||||
};
|
||||
const listener = {
|
||||
QueryInterface: ChromeUtils.generateQI([
|
||||
'nsIWebProgressListener',
|
||||
'nsISupportsWeakReference',
|
||||
]),
|
||||
onLocationChange(progress, request, location) {
|
||||
if (!progress.isTopLevel) return;
|
||||
if (location.spec === 'about:blank') return; // Initial, uncommitted context.
|
||||
emit('locationchange', {
|
||||
url: location.spec,
|
||||
canGoBack: browser.canGoBack,
|
||||
canGoForward: browser.canGoForward,
|
||||
});
|
||||
},
|
||||
onStateChange(progress, request, flags, status) {
|
||||
if (!progress.isTopLevel || !(flags & Ci.nsIWebProgressListener.STATE_IS_WINDOW))
|
||||
return;
|
||||
if (flags & Ci.nsIWebProgressListener.STATE_START) emit('loadstart');
|
||||
if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
|
||||
emit('loadend', { backgroundColor: 'rgb(255, 255, 255)' });
|
||||
emit('titlechange', browser.contentTitle || browser.currentURI.spec);
|
||||
if (!Components.isSuccessCode(status)) emit('error', { type: 'other', status });
|
||||
}
|
||||
},
|
||||
onProgressChange() {},
|
||||
onStatusChange() {},
|
||||
onSecurityChange() {},
|
||||
onContentBlockingEvent() {},
|
||||
};
|
||||
browser.addProgressListener(listener);
|
||||
entry = {
|
||||
actor,
|
||||
browser,
|
||||
listener,
|
||||
context,
|
||||
url,
|
||||
topContextId: browser.browsingContext.id,
|
||||
};
|
||||
this.entries.set(contextId, entry);
|
||||
}
|
||||
entry.url = url;
|
||||
entry.browser.loadURI(uri, {
|
||||
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!entry) throw new Error('VIEW_NOT_FOUND');
|
||||
const browser = entry.browser;
|
||||
if (operation === 'geometry') {
|
||||
const { left, top, width, height, visible } = geometry || {};
|
||||
if (![left, top, width, height].every(Number.isFinite)) throw new Error('INVALID_GEOMETRY');
|
||||
const shell = browser.ownerDocument.defaultView,
|
||||
screen = shell.document.getElementById('screen');
|
||||
const bounds = screen.getBoundingClientRect();
|
||||
const x = left - shell.mozInnerScreenX - bounds.left;
|
||||
const y = top - shell.mozInnerScreenY - bounds.top;
|
||||
const right = Math.min(480, x + width),
|
||||
bottom = Math.min(800, y + height);
|
||||
const clippedX = Math.max(0, x),
|
||||
clippedY = Math.max(0, y);
|
||||
Object.assign(browser.style, {
|
||||
left: clippedX + 'px',
|
||||
top: clippedY + 'px',
|
||||
width: Math.max(0, right - clippedX) + 'px',
|
||||
height: Math.max(0, bottom - clippedY) + 'px',
|
||||
visibility: visible && right > clippedX && bottom > clippedY ? 'visible' : 'hidden',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (operation === 'back') {
|
||||
if (browser.canGoBack) browser.goBack();
|
||||
return null;
|
||||
}
|
||||
if (operation === 'forward') {
|
||||
if (browser.canGoForward) browser.goForward();
|
||||
return null;
|
||||
}
|
||||
if (operation === 'reload') {
|
||||
browser.reload();
|
||||
return null;
|
||||
}
|
||||
if (operation === 'stop') {
|
||||
browser.stop();
|
||||
return null;
|
||||
}
|
||||
if (operation === 'canGoBack') return browser.canGoBack;
|
||||
if (operation === 'canGoForward') return browser.canGoForward;
|
||||
if (operation === 'close') {
|
||||
this.remove(contextId, entry);
|
||||
return null;
|
||||
}
|
||||
throw new Error('METHOD_NOT_SUPPORTED');
|
||||
},
|
||||
};
|
||||
28
host/actors/TouchChild.sys.mjs
Normal file
28
host/actors/TouchChild.sys.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Reuse Gecko's own responsive-design touch implementation. No application
|
||||
// gets privileged input methods; only physical mouse events are transformed.
|
||||
export class VulpesTouchChild extends JSWindowActorChild {
|
||||
handleEvent() {
|
||||
if (Services.cpmm.sharedData.get('vulpes:native-touch')) return;
|
||||
const allowed = Services.cpmm.sharedData.get('vulpes:host-contexts') || [];
|
||||
if (!allowed.includes(this.browsingContext.top.id) || this.simulator) return;
|
||||
const { require } = ChromeUtils.importESModule(
|
||||
'resource://devtools/shared/loader/Loader.sys.mjs',
|
||||
);
|
||||
const { TouchSimulator } = require('devtools/server/actors/emulation/touch-simulator');
|
||||
this.simulator = new TouchSimulator({ chromeEventHandler: this.contentWindow, emit() {} });
|
||||
this.simulator.start();
|
||||
this.contentWindow.addEventListener('contextmenu', (event) => event.preventDefault(), true);
|
||||
}
|
||||
receiveMessage(message) {
|
||||
if (message.name !== 'Vulpes:CancelTouch') throw new Error('METHOD_NOT_SUPPORTED');
|
||||
this.contentWindow.synthesizeTouchEvent('touchcancel', message.data, 0, {
|
||||
isAsyncEnabled: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
didDestroy() {
|
||||
try {
|
||||
this.simulator?.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
155
host/actors/VulpesChild.sys.mjs
Normal file
155
host/actors/VulpesChild.sys.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
export class VulpesChild extends JSWindowActorChild {
|
||||
handleEvent(event) {
|
||||
if(['focusin','pointerdown'].includes(event.type)) {
|
||||
if(this.editing) return;
|
||||
if(!Services.cpmm.sharedData.get('vulpes:native-input')) return;
|
||||
let element=event.target;
|
||||
if(element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
|
||||
const editable=element?.isContentEditable || (['input','textarea'].includes(element?.localName) && !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
|
||||
if(!editable || element.disabled || element.readOnly) {
|
||||
if(event.type==='pointerdown') this.sendAsyncMessage('Vulpes:InputFocus',null);
|
||||
return;
|
||||
}
|
||||
this.inputElement=element;
|
||||
this.sendAsyncMessage('Vulpes:InputFocus',this.inputState());
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'DOMDocElementInserted') return;
|
||||
const win = this.contentWindow;
|
||||
const request = (data) =>
|
||||
new win.Promise((resolve, reject) => {
|
||||
try {
|
||||
this.sendQuery('Vulpes:Request', Cu.cloneInto(data, {})).then(
|
||||
(value) => resolve(Cu.cloneInto(value, win)),
|
||||
() => reject(new win.DOMException('The host connection closed', 'AbortError')),
|
||||
);
|
||||
} catch (_) {
|
||||
reject(new win.DOMException('Invalid message', 'DataCloneError'));
|
||||
}
|
||||
});
|
||||
Cu.exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
|
||||
Cu.exportFunction(
|
||||
(frame, data) => {
|
||||
const native = Cu.unwaiveXrays(frame);
|
||||
if (native.ownerDocument !== this.document || native.localName !== 'iframe')
|
||||
throw new win.DOMException('Invalid view', 'SecurityError');
|
||||
const params = Cu.cloneInto(data, {});
|
||||
params.contextId = native.browsingContext.id;
|
||||
if (params.operation === 'geometry') {
|
||||
const rect = native.getBoundingClientRect();
|
||||
const samples = [
|
||||
[0.5, 0.5],
|
||||
[0.05, 0.05],
|
||||
[0.95, 0.05],
|
||||
[0.05, 0.95],
|
||||
[0.95, 0.95],
|
||||
];
|
||||
const unobscured = samples.every(
|
||||
([x, y]) =>
|
||||
this.document.elementFromPoint(
|
||||
rect.left + rect.width * x,
|
||||
rect.top + rect.height * y,
|
||||
) === native,
|
||||
);
|
||||
params.geometry = {
|
||||
left: rect.left + win.mozInnerScreenX,
|
||||
top: rect.top + win.mozInnerScreenY,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
visible:
|
||||
unobscured &&
|
||||
native.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }),
|
||||
};
|
||||
}
|
||||
return request(Cu.cloneInto({ version: 1, id: 0, method: 'views.request', params }, win));
|
||||
},
|
||||
win.navigator,
|
||||
{ defineAs: 'vulpesFrameRequest', allowCrossOriginArguments: true },
|
||||
);
|
||||
Cu.exportFunction(
|
||||
(id) => {
|
||||
for (const frame of this.document.querySelectorAll('iframe'))
|
||||
if (frame.browsingContext?.id === id) return frame;
|
||||
return null;
|
||||
},
|
||||
win.navigator,
|
||||
{ defineAs: 'vulpesFrameForContext' },
|
||||
);
|
||||
}
|
||||
inputState() {
|
||||
const e=this.inputElement;
|
||||
const text=e.isContentEditable ? e.textContent : e.value;
|
||||
const selection=this.contentWindow.getSelection();
|
||||
let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start;
|
||||
if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) {
|
||||
const range=this.document.createRange();range.selectNodeContents(e);
|
||||
range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length;
|
||||
range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length;
|
||||
[start,end]=[Math.min(start,end),Math.max(start,end)];
|
||||
}
|
||||
return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'),
|
||||
inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''),
|
||||
text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end};
|
||||
}
|
||||
edit(p) {
|
||||
const e=this.inputElement,win=this.contentWindow;
|
||||
if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED');
|
||||
this.editing=true;
|
||||
try {e.focus();} finally {this.editing=false;}
|
||||
// A touch can focus a new contenteditable while Gecko retains the previous
|
||||
// field's selection. execCommand follows that selection, not activeElement.
|
||||
if(e.isContentEditable) {
|
||||
const selection=win.getSelection();
|
||||
if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) {
|
||||
const range=this.document.createRange();
|
||||
range.selectNodeContents(e);range.collapse(false);
|
||||
selection.removeAllRanges();selection.addRange(range);
|
||||
}
|
||||
}
|
||||
if(p.operation==='key') {
|
||||
let key=typeof p.key==='object'?p.key.key:p.key;
|
||||
if(key===8 || key==='Backspace') this.document.execCommand('delete');
|
||||
else if(key===13 || key==='Enter') {
|
||||
const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true};
|
||||
const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options));
|
||||
if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak');
|
||||
e.dispatchEvent(new win.KeyboardEvent('keyup',options));
|
||||
} else {
|
||||
const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
|
||||
if(text.length>16) throw Error('INVALID_VALUE');
|
||||
this.document.execCommand('insertText',false,text);
|
||||
}
|
||||
} else if(['selection','replace'].includes(p.operation)) {
|
||||
const state=this.inputState();
|
||||
const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0);
|
||||
const length=p.length||0;
|
||||
if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE');
|
||||
if(e.isContentEditable) {
|
||||
const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT);
|
||||
const points=[];let offset=0,node;
|
||||
while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;}
|
||||
const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];};
|
||||
const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length));
|
||||
win.getSelection().removeAllRanges();win.getSelection().addRange(range);
|
||||
} else e.setSelectionRange(start,start+length);
|
||||
if(p.operation==='replace') {
|
||||
if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE');
|
||||
this.document.execCommand('insertText',false,p.text);
|
||||
}
|
||||
} else throw Error('METHOD_NOT_SUPPORTED');
|
||||
return this.inputState();
|
||||
}
|
||||
receiveMessage(message) {
|
||||
if(message.name==='Vulpes:Edit') return this.edit(message.data);
|
||||
if (message.name === 'Vulpes:ViewAlive') {
|
||||
return Array.from(this.document.querySelectorAll('iframe')).some(
|
||||
(frame) => frame.isConnected && frame.browsingContext?.id === message.data.contextId,
|
||||
);
|
||||
}
|
||||
if (message.name !== 'Vulpes:Event') return;
|
||||
const win = this.contentWindow;
|
||||
win.dispatchEvent(
|
||||
new win.CustomEvent('vulpes-service-event', { detail: Cu.cloneInto(message.data, win) }),
|
||||
);
|
||||
}
|
||||
}
|
||||
11
host/actors/VulpesParent.sys.mjs
Normal file
11
host/actors/VulpesParent.sys.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Runtime } from 'resource://vulpes/host/Runtime.sys.mjs';
|
||||
export class VulpesParent extends JSWindowActorParent {
|
||||
receiveMessage(message) {
|
||||
if(message.name==='Vulpes:InputFocus') {Runtime.input.changed(this,message.data);return;}
|
||||
if (message.name !== 'Vulpes:Request') throw new Error('Unknown host message');
|
||||
return Runtime.request(this, message.data);
|
||||
}
|
||||
didDestroy() {
|
||||
Runtime.cleanup(this);
|
||||
}
|
||||
}
|
||||
3
host/bootstrap/vulpes-autoconfig.js
Normal file
3
host/bootstrap/vulpes-autoconfig.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pref('general.config.filename', 'vulpes.cfg');
|
||||
pref('general.config.obscure_value', 0);
|
||||
pref('general.config.sandbox_enabled', false);
|
||||
11
host/bootstrap/vulpes.cfg
Normal file
11
host/bootstrap/vulpes.cfg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Local Vulpes bootstrap, loaded only for the isolated host launcher.
|
||||
try {
|
||||
if (Services.env.get('VULPES_HOST_ROOT')) {
|
||||
var root = Components.classes['@mozilla.org/file/local;1'].createInstance(Components.interfaces.nsIFile);
|
||||
root.initWithPath(Services.env.get('VULPES_HOST_ROOT'));
|
||||
var baseURI = Services.io.newFileURI(root);
|
||||
var manifest = root.clone(); manifest.append('chrome.manifest');
|
||||
var vulpesRegistration = Components.classes['@mozilla.org/addons/addon-manager-startup;1'].getService(Components.interfaces.amIAddonManagerStartup).registerChrome(Services.io.newFileURI(manifest), [['content','vulpes','./']]);
|
||||
Services.io.getProtocolHandler('resource').QueryInterface(Components.interfaces.nsIResProtocolHandler).setSubstitution('vulpes',Services.io.newURI('../',null,baseURI));
|
||||
}
|
||||
} catch(e) { Components.utils.reportError(e); }
|
||||
16
host/branding.json
Normal file
16
host/branding.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"search": {
|
||||
"name": "Vulpes Browser",
|
||||
"locales": {
|
||||
"fr": {
|
||||
"name": "Navigateur Vulpes"
|
||||
},
|
||||
"en-US": {
|
||||
"name": "Vulpes Browser"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"284": "/shared/resources/branding/vulpes-browser.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
host/camera.js
Normal file
55
host/camera.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
addEventListener(
|
||||
'DOMContentLoaded',
|
||||
() => {
|
||||
'use strict';
|
||||
const options = window.arguments[0];
|
||||
let stream,
|
||||
completed = false,
|
||||
closed = false;
|
||||
const status = document.getElementById('status'),
|
||||
capture = document.getElementById('capture'),
|
||||
video = document.getElementById('preview');
|
||||
const stop = () => stream?.getTracks().forEach((track) => track.stop());
|
||||
addEventListener('unload', () => {
|
||||
closed = true;
|
||||
stop();
|
||||
if (!completed) options.cancel();
|
||||
});
|
||||
document.getElementById('cancel').onclick = () => window.close();
|
||||
capture.onclick = async () => {
|
||||
capture.disabled = true;
|
||||
try {
|
||||
const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9));
|
||||
if (!blob) throw Error('Capture failed');
|
||||
await options.finish(blob);
|
||||
completed = true;
|
||||
stop();
|
||||
window.close();
|
||||
} catch (e) {
|
||||
status.textContent = String(e);
|
||||
capture.disabled = false;
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
||||
if (closed) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
capture.disabled = false;
|
||||
status.textContent = 'La webcam est active. / Webcam is active.';
|
||||
} catch (e) {
|
||||
stop();
|
||||
status.textContent = 'Webcam indisponible / Webcam unavailable: ' + e.name;
|
||||
}
|
||||
})();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
5
host/camera.xhtml
Normal file
5
host/camera.xhtml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Vulpes — Webcam</title>
|
||||
<style>body{margin:20px;background:#142532;color:white;font:16px sans-serif}video{width:100%;height:400px;object-fit:contain;background:black}button{padding:12px;margin:8px;font:inherit}p{min-height:2em}</style>
|
||||
<script src="chrome://vulpes/content/camera.js" defer="defer"></script></head>
|
||||
<body><h1>Vulpes — Webcam</h1><video id="preview" autoplay="autoplay" muted="muted" playsinline="playsinline"></video><p id="status"></p><button id="capture" disabled="disabled">Prendre une photo / Take a photo</button><button id="cancel">Fermer / Close</button></body></html>
|
||||
2
host/chrome.manifest
Normal file
2
host/chrome.manifest
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
content vulpes ./
|
||||
resource vulpes ../
|
||||
198
host/desktop.css
Normal file
198
host/desktop.css
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
@import url('/shared/elements/vulpes-fonts/fonts.css');
|
||||
|
||||
/* Desktop-only additions; preserve each Gaia application's own theme. */
|
||||
.vulpes-import {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 5.5rem;
|
||||
z-index: 100;
|
||||
background: #007b88;
|
||||
color: white;
|
||||
font: 1.6rem sans-serif;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 1.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
#homescreen > .chrome {
|
||||
background: transparent;
|
||||
}
|
||||
#homescreen .urlbar {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: white;
|
||||
}
|
||||
#homescreen .chrome-title-container {
|
||||
color: white;
|
||||
}
|
||||
html[data-vulpes-app='homescreen'] #page-indicator-header {
|
||||
background: transparent;
|
||||
}
|
||||
html[data-vulpes-app='homescreen'] #page-indicator {
|
||||
align-items: center;
|
||||
}
|
||||
html[data-vulpes-app='homescreen'] #page-indicator span {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Whole CSS pixels; the short viewport edge keeps landscape icons usable. */
|
||||
html[data-vulpes-app='homescreen'] gaia-app-icon {
|
||||
width: round(nearest, 32vmin, 1px);
|
||||
}
|
||||
#screen:has(#homescreen.active):not(.rocketbar-focused):not(.utility-tray):not(.locked)
|
||||
#statusbar.light {
|
||||
filter: none;
|
||||
}
|
||||
html[data-vulpes-app='email'] input:not([type='checkbox']):not([type='radio']):not([type='range']),
|
||||
html[data-vulpes-app='email'] textarea {
|
||||
appearance: none;
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
html[data-vulpes-app='email'] input:focus,
|
||||
html[data-vulpes-app='email'] textarea:focus {
|
||||
border-color: #008d99;
|
||||
outline: 2px solid #008d99;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.vulpes-capability {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: #f4f4f4;
|
||||
color: #222;
|
||||
padding: 3rem;
|
||||
font: 1.8rem/1.5 sans-serif;
|
||||
overflow: auto;
|
||||
}
|
||||
.vulpes-capability h1 {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
html[data-vulpes-app='gallery'] body:not(.thumbnailListView) > .vulpes-import {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shared native integration controls on desktop and Android. */
|
||||
.vulpes-error-dialog {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 420px;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
font: 16px/1.4 FiraSans, sans-serif;
|
||||
}
|
||||
.vulpes-error-dialog::backdrop { background: rgb(0 0 0 / 55%); }
|
||||
.vulpes-error-dialog h2 { margin: 0 0 12px; font-size: 22px; font-weight: 300; }
|
||||
.vulpes-error-dialog p { margin: 0 0 20px; }
|
||||
.vulpes-error-dialog button { width: 100%; }
|
||||
.vulpes-platform-button {
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #0755be;
|
||||
color: white;
|
||||
font: 16px sans-serif;
|
||||
margin: 8px;
|
||||
}
|
||||
.vulpes-platform-button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
#vulpes-release-info {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Unsupported Gaia camera controls keep their position without accepting gestures. */
|
||||
html[data-vulpes-app='camera'] .hud .vulpes-camera-unavailable,
|
||||
html[data-vulpes-app='camera'] .mode-switch.vulpes-camera-unavailable {
|
||||
opacity: 0.35 !important;
|
||||
visibility: visible !important;
|
||||
pointer-events: auto;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
/* Versions can wrap on narrow screens without overlapping their labels. */
|
||||
#vulpes-system-info > li {
|
||||
box-sizing: border-box;
|
||||
min-height: 5.8rem;
|
||||
height: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
#vulpes-system-info > li > span,
|
||||
#vulpes-system-info > li > small {
|
||||
display: block;
|
||||
position: static;
|
||||
float: none;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
#vulpes-system-info > li > small {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Text uses Gaia's bundled face on every host. Icon pseudo-elements retain
|
||||
their own font; text styling must not depend on the Linux default font. */
|
||||
html, body, button, input, textarea, select, label,
|
||||
h1, h2, h3, h4, h5, h6, #statusbar-time,
|
||||
#notification-some, #notification-none {
|
||||
font-family: FiraSans, sans-serif !important;
|
||||
}
|
||||
|
||||
/* An unset owner message must not paint an empty card on the lockscreen. */
|
||||
#lockscreen-message:empty { display:none; }
|
||||
/* Gaia owns edge gestures; avoid a second host overscroll effect. */
|
||||
html[data-vulpes-app] { overscroll-behavior:none; }
|
||||
|
||||
|
||||
/* Native selects now paint their own arrow; the old Gecko arrow-hiding offset
|
||||
clips Fira descenders on current Gecko. */
|
||||
html[data-vulpes-app] .button select,
|
||||
html[data-vulpes-app] .bb-button select {
|
||||
appearance: none;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.4;
|
||||
padding-block: 0.35rem;
|
||||
}
|
||||
/* The tab buttons may receive focus without scrolling the whole phone page. */
|
||||
html[data-vulpes-app='communications'],
|
||||
html[data-vulpes-app='communications'] body {
|
||||
overflow: clip;
|
||||
}
|
||||
html[data-vulpes-app='video'] #overlay-content,
|
||||
html[data-vulpes-app='video'] #in-use-overlay-content {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
html[data-vulpes-app='video'] #overlay-text,
|
||||
html[data-vulpes-app='video'] #in-use-overlay-text {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Home overlays wallpaper only. Keep the last icon row clear of its button. */
|
||||
html[data-vulpes-app='homescreen'] #apps-panel > .scrollable {
|
||||
padding-bottom: 44px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
html[data-vulpes-app='homescreen'] #apps-panel > .scrollable {
|
||||
padding-bottom: 0;
|
||||
padding-right: 44px;
|
||||
}
|
||||
}
|
||||
html[data-vulpes-app='communications'] body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Camera gestures must not magnify the application chrome. */
|
||||
html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-action:none; }
|
||||
526
host/gaia-camera.js
Normal file
526
host/gaia-camera.js
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
|
||||
(() => {
|
||||
if (location.hostname !== 'camera.localhost') return;
|
||||
let cameras = ['back'],
|
||||
devices = new Map(),
|
||||
current = null,
|
||||
generation = 0;
|
||||
let cameraError = null;
|
||||
function recordError(error) {
|
||||
cameraError = { name: error.name || 'Error', message: error.message || '' };
|
||||
console.warn('Vulpes Camera:', cameraError.name, cameraError.message);
|
||||
}
|
||||
function renderError() {
|
||||
const node = document.querySelector('.overlay[data-type="request-fail"] #overlay-text');
|
||||
if (!node || !cameraError) return;
|
||||
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
|
||||
const messages = {
|
||||
NotAllowedError: [
|
||||
'Accès à la caméra refusé. Autorisez Photo à utiliser la caméra dans les permissions du système ou du navigateur.',
|
||||
'Camera access denied. Allow Camera to use the device in system or browser permissions.',
|
||||
],
|
||||
NotSupportedError: [
|
||||
'L’accès au capteur photo n’est pas encore disponible sur ce modèle.',
|
||||
'Access to the camera sensor is not yet available on this model.',
|
||||
],
|
||||
NotFoundError: [
|
||||
'Aucune caméra détectée. Vérifiez son branchement.',
|
||||
'No camera detected. Check its connection.',
|
||||
],
|
||||
NotReadableError: [
|
||||
'Impossible de démarrer la caméra. Elle peut être occupée, ou son accès matériel a échoué.',
|
||||
'Unable to start the camera. It may be busy, or hardware access has failed.',
|
||||
],
|
||||
OverconstrainedError: [
|
||||
'Le format demandé n’est pas disponible sur cette caméra.',
|
||||
'The requested format is not available on this camera.',
|
||||
],
|
||||
AbortError: [
|
||||
'L’ouverture de la caméra a été interrompue. Revenez dans Photo pour réessayer.',
|
||||
'Camera startup was interrupted. Return to Camera to try again.',
|
||||
],
|
||||
};
|
||||
const text = (messages[cameraError.name] || [
|
||||
'Impossible d’ouvrir la caméra.',
|
||||
'Unable to open the camera.',
|
||||
])[fr ? 0 : 1];
|
||||
node.removeAttribute('data-l10n-id');
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
new MutationObserver(renderError).observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
addEventListener('localized', renderError);
|
||||
const nativeHidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden').get;
|
||||
let frameVisible = true;
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
get: () => !frameVisible || nativeHidden.call(document),
|
||||
});
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
get: () => (document.hidden ? 'hidden' : 'visible'),
|
||||
});
|
||||
addEventListener('message', (event) => {
|
||||
if (
|
||||
event.source !== parent ||
|
||||
event.origin !== 'http://system.localhost:' + location.port ||
|
||||
event.data?.type !== 'vulpes-camera-visibility'
|
||||
)
|
||||
return;
|
||||
const visible = event.data.visible === true;
|
||||
if (visible === frameVisible) return;
|
||||
frameVisible = visible;
|
||||
document.dispatchEvent(new Event('visibilitychange', { bubbles: true }));
|
||||
});
|
||||
const nativeSource = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject');
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return nativeSource.get.call(this);
|
||||
},
|
||||
set(stream) {
|
||||
nativeSource.set.call(this, stream);
|
||||
},
|
||||
});
|
||||
function emit(target, type, detail = {}) {
|
||||
const event = new Event(type);
|
||||
Object.assign(event, detail);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
async function discover(stream) {
|
||||
if (stream.vulpesNative) {
|
||||
cameras = ['back', 'front'];
|
||||
return;
|
||||
}
|
||||
// Device enumeration may wait indefinitely for focus in embedded desktop contexts.
|
||||
// The already-open stream remains usable even when no device list is exposed.
|
||||
let timeout;
|
||||
const entries = await Promise.race([
|
||||
navigator.mediaDevices.enumerateDevices(),
|
||||
new Promise((resolve) => (timeout = setTimeout(() => resolve([]), 1500))),
|
||||
]).finally(() => clearTimeout(timeout));
|
||||
const list = entries.filter((d) => d.kind === 'videoinput');
|
||||
const settings = stream.getVideoTracks()[0].getSettings();
|
||||
const active = list.find((d) => d.deviceId === settings.deviceId) || list[0];
|
||||
const back = list.find((d) => /back|rear|environment/i.test(d.label));
|
||||
const front = list.find((d) => /front|user|face/i.test(d.label));
|
||||
const first =
|
||||
back ||
|
||||
(settings.facingMode === 'user' && list.length > 1 ? list.find((d) => d !== active) : active);
|
||||
devices = new Map();
|
||||
if (first) devices.set('back', first.deviceId);
|
||||
const second = front && front !== first ? front : list.find((d) => d !== first);
|
||||
if (second) devices.set('front', second.deviceId);
|
||||
cameras = devices.size ? [...devices.keys()] : ['back'];
|
||||
}
|
||||
async function nativeStream(name) {
|
||||
let session;
|
||||
const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session});
|
||||
const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'});
|
||||
session = meta.session;
|
||||
const canvas = document.createElement('canvas');
|
||||
const sideways = meta.orientation % 180 !== 0;
|
||||
canvas.width = sideways ? meta.height : meta.width;
|
||||
canvas.height = sideways ? meta.width : meta.height;
|
||||
// Convert NV21 in a shader instead of blocking Gaia's UI with one JS loop
|
||||
// per pixel. The two textures contain Y and interleaved V/U respectively.
|
||||
const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true});
|
||||
if (!gl) {
|
||||
await call({action:'stop'});
|
||||
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
|
||||
}
|
||||
function shader(type, source) {
|
||||
const value = gl.createShader(type);
|
||||
gl.shaderSource(value, source); gl.compileShader(value);
|
||||
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
|
||||
return value;
|
||||
}
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, shader(gl.VERTEX_SHADER, `
|
||||
attribute vec2 position; varying vec2 coord;
|
||||
void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
|
||||
`));
|
||||
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
|
||||
precision mediump float; varying vec2 coord;
|
||||
uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
|
||||
void main() {
|
||||
vec2 p=coord;
|
||||
if(rotation==90.) p=vec2(coord.y,1.-coord.x);
|
||||
else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
|
||||
else if(rotation==180.) p=1.-coord;
|
||||
float y=1.16438356*(texture2D(luma,p).r-16./255.);
|
||||
vec4 vu=texture2D(chroma,p);
|
||||
float v=vu.r-128./255.; float u=vu.a-128./255.;
|
||||
gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
|
||||
}
|
||||
`));
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
|
||||
gl.useProgram(program);
|
||||
const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
|
||||
const position=gl.getAttribLocation(program,'position');
|
||||
gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
|
||||
gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
|
||||
const textures = ['luma','chroma'].map((name,index) => {
|
||||
const texture=gl.createTexture();gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
|
||||
gl.uniform1i(gl.getUniformLocation(program,name),index);return texture;
|
||||
});
|
||||
const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
|
||||
let stopped = false, timer, stream, sequence = -1, closing;
|
||||
const stop = () => {
|
||||
if (closing) return closing;
|
||||
stopped = true; clearTimeout(timer);
|
||||
closing = call({action:'stop'}).finally(() => {
|
||||
textures.forEach(texture => gl.deleteTexture(texture));
|
||||
gl.deleteBuffer(buffer); gl.deleteProgram(program);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
});
|
||||
return closing;
|
||||
};
|
||||
const draw = async () => {
|
||||
const frame = await call({action:'frame'});
|
||||
if (stopped || !frame.data || frame.sequence === sequence) return false;
|
||||
const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
|
||||
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
|
||||
sequence = frame.sequence;
|
||||
const start=performance.now();
|
||||
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
|
||||
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
|
||||
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
|
||||
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
||||
timing.frames++;timing.conversionMs+=performance.now()-start;
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
const deadline = Date.now() + 6000;
|
||||
while (!(await draw())) {
|
||||
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
stream = canvas.captureStream(24);
|
||||
stream.vulpesTiming = timing;
|
||||
stream.vulpesNative = true;
|
||||
stream.vulpesCameraMetadata = meta;
|
||||
stream.vulpesCameraControl = call;
|
||||
stream.vulpesCameraClose = stop;
|
||||
for (const track of stream.getTracks()) {
|
||||
const originalStop = track.stop.bind(track);
|
||||
track.stop = () => { stop().catch(recordError); originalStop(); };
|
||||
}
|
||||
const next = async () => {
|
||||
if (stopped) return;
|
||||
const started = performance.now();
|
||||
try { await draw(); }
|
||||
catch (error) {
|
||||
if (!stopped) { recordError(error); stream.getTracks().forEach(track => track.stop()); }
|
||||
return;
|
||||
}
|
||||
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/24-(performance.now()-started)));
|
||||
};
|
||||
timer = setTimeout(next, 1000/24);
|
||||
return stream;
|
||||
} catch (error) { await stop().catch(() => {}); throw error; }
|
||||
}
|
||||
async function getCamera(name, config = {}) {
|
||||
if (config.mode && config.mode !== 'picture')
|
||||
throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
|
||||
const ticket = ++generation;
|
||||
await current?.release?.();
|
||||
const selected = devices.get(name);
|
||||
|
||||
cameraError = null;
|
||||
let stream;
|
||||
try {
|
||||
const platform = await VulpesCompat.call('platform.capabilities', {});
|
||||
if (platform.platform === 'tundra' && !platform.camera)
|
||||
throw new DOMException('Native camera adapter unavailable', 'NotSupportedError');
|
||||
stream = platform.platform === 'tundra' ? await nativeStream(name) : await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
...(selected
|
||||
? { deviceId: { exact: selected } }
|
||||
: { facingMode: { ideal: 'environment' } }),
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
recordError(error);
|
||||
throw error;
|
||||
}
|
||||
if (document.hidden || ticket !== generation) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
const error = new DOMException('Camera hidden', 'AbortError');
|
||||
recordError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
current = stream;
|
||||
let preview;
|
||||
try {
|
||||
await discover(stream);
|
||||
if (document.hidden || ticket !== generation)
|
||||
throw new DOMException('Camera hidden', 'AbortError');
|
||||
|
||||
const track = stream.getVideoTracks()[0];
|
||||
const settings = track.getSettings();
|
||||
const size = { width: settings.width, height: settings.height };
|
||||
preview = document.createElement('video');
|
||||
preview.muted = true;
|
||||
preview.playsInline = true;
|
||||
preview.srcObject = stream;
|
||||
preview.dataset.vulpesCapture = '';
|
||||
preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
|
||||
document.body.append(preview);
|
||||
let previewTimeout;
|
||||
await Promise.race([
|
||||
preview.play(),
|
||||
new Promise((_, reject) => {
|
||||
previewTimeout = setTimeout(
|
||||
() => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
|
||||
10000,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(previewTimeout));
|
||||
if (document.hidden || ticket !== generation)
|
||||
throw new DOMException('Camera hidden', 'AbortError');
|
||||
|
||||
const native = stream.vulpesCameraMetadata;
|
||||
const pictureSizes = native?.pictureSizes || [size];
|
||||
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
|
||||
let focusArea = null, zoom = 1, releasing;
|
||||
const configuration = () => ({
|
||||
mode: 'picture',
|
||||
previewSize: size,
|
||||
pictureSize,
|
||||
recorderProfile: null,
|
||||
});
|
||||
Object.assign(stream, {
|
||||
capabilities: {
|
||||
pictureSizes,
|
||||
previewSizes: [size],
|
||||
thumbnailSizes: [],
|
||||
recorderProfiles: {},
|
||||
flashModes: ['off'],
|
||||
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
|
||||
sceneModes: ['auto'],
|
||||
whiteBalanceModes: ['auto'],
|
||||
isoModes: ['auto'],
|
||||
zoomRatios: native?.zoomRatios || [1],
|
||||
maxFocusAreas: native?.maxFocusAreas || 0,
|
||||
maxMeteringAreas: 0,
|
||||
maxDetectedFaces: 0,
|
||||
},
|
||||
resumeContinuousFocus() {},
|
||||
sensorAngle: 0,
|
||||
focusMode: 'fixed',
|
||||
flashMode: 'off',
|
||||
zoom: 1,
|
||||
async setConfiguration(value) {
|
||||
if (value.pictureSize) stream.setPictureSize(value.pictureSize);
|
||||
if (value.mode && value.mode !== 'picture')
|
||||
throw new DOMException('Video unavailable', 'NotSupportedError');
|
||||
queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' }));
|
||||
return configuration();
|
||||
},
|
||||
setPictureSize(value) {
|
||||
const selected = pictureSizes.find(s=>s.width===value.width && s.height===value.height);
|
||||
if (!selected) throw new DOMException('Unsupported picture size','NotSupportedError');
|
||||
pictureSize = selected;
|
||||
},
|
||||
getPictureSize: () => pictureSize,
|
||||
setFocusAreas(areas) {
|
||||
focusArea = areas?.[0] || null;
|
||||
},
|
||||
setMeteringAreas() {},
|
||||
async autoFocus() {
|
||||
if (!native?.focusModes.includes('auto')) return;
|
||||
emit(stream,'focus',{newState:'focusing'});
|
||||
let area = null;
|
||||
if (focusArea) {
|
||||
const rotate = (x,y) => native.orientation===90 ? [y,-x] :
|
||||
native.orientation===270 ? [-y,x] : native.orientation===180 ? [-x,-y] : [x,y];
|
||||
const points = [rotate(focusArea.left,focusArea.top),rotate(focusArea.right,focusArea.bottom)];
|
||||
const clamp = n=>Math.max(-1000,Math.min(1000,Math.round(n)));
|
||||
area={left:clamp(Math.min(...points.map(p=>p[0]))),right:clamp(Math.max(...points.map(p=>p[0]))),
|
||||
top:clamp(Math.min(...points.map(p=>p[1]))),bottom:clamp(Math.max(...points.map(p=>p[1])))};
|
||||
}
|
||||
try {
|
||||
await stream.vulpesCameraControl({action:'focus',area});
|
||||
emit(stream,'focus',{newState:'focused'});
|
||||
} catch(error) {emit(stream,'focus',{newState:'failed'});throw error;}
|
||||
},
|
||||
setThumbnailSize() {},
|
||||
async takePicture() {
|
||||
if (native) {
|
||||
const result=await stream.vulpesCameraControl({action:'picture',size:pictureSize});
|
||||
emit(stream,'shutter');
|
||||
return new Blob([result.data],{type:'image/jpeg'});
|
||||
}
|
||||
if (track.readyState !== 'live' || !preview.videoWidth)
|
||||
throw new DOMException('Camera inactive', 'InvalidStateError');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = preview.videoWidth;
|
||||
canvas.height = preview.videoHeight;
|
||||
canvas.getContext('2d').drawImage(preview, 0, 0);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
|
||||
if (!blob) throw new Error('JPEG capture failed');
|
||||
emit(stream, 'shutter');
|
||||
return blob;
|
||||
},
|
||||
resumePreview() {
|
||||
emit(stream, 'previewstatechange', { newState: 'started' });
|
||||
},
|
||||
release() {
|
||||
if (releasing) return releasing;
|
||||
// Detach first: visibility changes and Gaia may release the same stream
|
||||
// again while its hardware worker is still shutting down.
|
||||
if (current === stream) current = null;
|
||||
releasing = Promise.resolve().then(async () => {
|
||||
for (const video of document.querySelectorAll('video')) {
|
||||
if (video.srcObject === stream) { video.pause(); video.srcObject = null; }
|
||||
}
|
||||
preview.remove();
|
||||
await stream.vulpesCameraClose?.();
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
});
|
||||
return releasing;
|
||||
},
|
||||
async startRecording() {
|
||||
throw new DOMException('Video unavailable', 'NotSupportedError');
|
||||
},
|
||||
stopRecording() {},
|
||||
});
|
||||
if (native) Object.defineProperty(stream,'zoom',{
|
||||
configurable:true, get:()=>zoom,
|
||||
set(value) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
const ratios=native.zoomRatios;
|
||||
const nearest=ratios.reduce((a,b)=>Math.abs(a-value)<Math.abs(b-value)?a:b);
|
||||
zoom=nearest;
|
||||
stream.vulpesCameraControl({action:'zoom',zoom:nearest}).then(result=>{zoom=result.zoom;})
|
||||
.catch(recordError);
|
||||
}
|
||||
});
|
||||
dispatchEvent(
|
||||
new CustomEvent('vulpes-camera-capabilities', {
|
||||
detail: { cameras: [...cameras], flash: false, video: false },
|
||||
}),
|
||||
);
|
||||
return { camera: stream, configuration: configuration() };
|
||||
} catch (error) {
|
||||
recordError(error);
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
if (preview) {
|
||||
preview.srcObject = null;
|
||||
preview.remove();
|
||||
}
|
||||
if (current === stream) current = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Object.defineProperty(navigator, 'mozCameras', {
|
||||
value: { getListOfCameras: () => [...cameras], getCamera },
|
||||
});
|
||||
const stop = () => {
|
||||
// A permission dialog can hide the document before any stream exists.
|
||||
// Keep that request valid; its completion still checks visibility.
|
||||
if (!current) return;
|
||||
++generation;
|
||||
if (current?.release) current.release().catch(recordError);
|
||||
else current?.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
addEventListener('pagehide', stop);
|
||||
addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stop();
|
||||
});
|
||||
// Preserve familiar positions. Unavailable controls remain visible, labelled and inert.
|
||||
function updateControls() {
|
||||
const app = window.app;
|
||||
if (!app || !document.querySelector('.hud')) return;
|
||||
app.camera.cameraList = [...cameras];
|
||||
app.settings.cameras.filterOptions(cameras);
|
||||
document.querySelector('.hud').setAttribute('camera-enabled', String(cameras.length > 1));
|
||||
const controls = [
|
||||
[
|
||||
'.js-flash',
|
||||
true,
|
||||
'Flash indisponible dans cette Preview',
|
||||
'Flash unavailable in this Preview',
|
||||
'flash-off',
|
||||
],
|
||||
[
|
||||
'.js-camera',
|
||||
cameras.length < 2,
|
||||
'Une seule caméra disponible',
|
||||
'Only one camera available',
|
||||
'toggle-camera-rear',
|
||||
],
|
||||
['.mode-switch', true, 'Vidéo pas encore disponible', 'Video is not available yet', null],
|
||||
];
|
||||
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
|
||||
for (const [selector, disabled, french, english, icon] of controls) {
|
||||
const e = document.querySelector(selector);
|
||||
if (!e) continue;
|
||||
e.classList.toggle('vulpes-camera-unavailable', disabled);
|
||||
e.setAttribute('aria-disabled', String(disabled));
|
||||
if (!disabled) {
|
||||
e.removeAttribute('title');
|
||||
e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
|
||||
}
|
||||
if (disabled) {
|
||||
e.removeAttribute('data-l10n-id');
|
||||
e.setAttribute('aria-label', fr ? french : english);
|
||||
e.title = fr ? french : english;
|
||||
for (const child of e.querySelectorAll('[role=button]')) {
|
||||
child.setAttribute('aria-disabled', 'true');
|
||||
child.removeAttribute('data-l10n-id');
|
||||
child.setAttribute('aria-label', e.title);
|
||||
}
|
||||
if (icon) e.dataset.icon = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bound = false;
|
||||
const binding = setInterval(() => {
|
||||
if (!window.app || !document.querySelector('.hud') || bound) return;
|
||||
bound = true;
|
||||
clearInterval(binding);
|
||||
app.on('settings:configured', updateControls);
|
||||
app.on('localized', updateControls);
|
||||
updateControls();
|
||||
}, 100);
|
||||
addEventListener('pagehide', () => clearInterval(binding));
|
||||
addEventListener('vulpes-camera-capabilities', updateControls);
|
||||
// A pinch belongs to the viewfinder, never to the surrounding Gaia document.
|
||||
addEventListener('touchmove', event => {
|
||||
if (event.touches.length > 1) event.preventDefault();
|
||||
}, {passive:false});
|
||||
addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
if (event.target.closest?.('.vulpes-camera-unavailable')) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
for (const name of ['touchstart', 'touchmove', 'touchend', 'pointerdown'])
|
||||
addEventListener(
|
||||
name,
|
||||
(event) => {
|
||||
if (event.target.closest?.('.vulpes-camera-unavailable')) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false },
|
||||
);
|
||||
})();
|
||||
743
host/gaia-compat.js
Normal file
743
host/gaia-compat.js
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
// Gaia compatibility API. The host checks permissions for each request.
|
||||
(() => {
|
||||
'use strict';
|
||||
let sequence = 0;
|
||||
window.__vulpesDiagnostics = [];
|
||||
function record(kind, values) {
|
||||
window.__vulpesDiagnostics.push({
|
||||
kind,
|
||||
message: values.map((v) => String(v) + (v?.stack ? '\n' + v.stack : '')).join(' '),
|
||||
});
|
||||
if (window.__vulpesDiagnostics.length > 100) window.__vulpesDiagnostics.shift();
|
||||
}
|
||||
for (const kind of ['error', 'warn']) {
|
||||
const original = console[kind].bind(console);
|
||||
console[kind] = (...values) => {
|
||||
record(kind, values);
|
||||
original(...values);
|
||||
};
|
||||
}
|
||||
addEventListener('unhandledrejection', (event) => record('rejection', [event.reason]));
|
||||
addEventListener('error', (event) =>
|
||||
record('error', [event.message, event.filename, event.lineno]),
|
||||
);
|
||||
// Desktop transports Blobs directly; Android installs a JSON-compatible codec.
|
||||
const battery = Object.assign(new EventTarget(), {level:NaN, charging:false, chargingTime:Infinity, dischargingTime:Infinity});
|
||||
Object.defineProperty(navigator, 'battery', {value:battery});
|
||||
async function refreshBattery() {
|
||||
try {
|
||||
const state = await call('platform.snapshot', {});
|
||||
const next = state.battery;
|
||||
if (next && Number.isFinite(next.level) && next.level >= 0 && next.level <= 1) {
|
||||
const oldLevel = battery.level, oldCharging = battery.charging;
|
||||
battery.level = next.level; battery.charging = !!next.charging;
|
||||
if (oldLevel !== battery.level) battery.dispatchEvent(new Event('levelchange'));
|
||||
if (oldCharging !== battery.charging) battery.dispatchEvent(new Event('chargingchange'));
|
||||
}
|
||||
} catch (_) { /* An unknown battery is not an empty battery. */ }
|
||||
}
|
||||
if (['system.localhost', 'settings.localhost', 'camera.localhost'].includes(location.hostname)) {
|
||||
addEventListener('DOMContentLoaded', () => { refreshBattery(); setInterval(refreshBattery, 5000); }, {once:true});
|
||||
}
|
||||
|
||||
if (['system','communications','callscreen'].includes(location.hostname.split('.')[0]) && window.VulpesNativeTelephony) {
|
||||
// Callscreen only needs the SIM count and CDMA classification here. SIM
|
||||
// provisioning and registration remain unavailable until their own API exists.
|
||||
if (location.hostname === 'callscreen.localhost') {
|
||||
window.close = () => call('platform.callScreenClosed',{}).catch(console.error);
|
||||
Object.defineProperty(navigator,'mozIccManager',{value:Object.assign(new EventTarget(),{iccIds:[],getIccById:()=>null})});
|
||||
Object.defineProperty(navigator,'mozMobileConnections',{value:[{voice:{type:null}}]});
|
||||
}
|
||||
const telephony = Object.assign(new EventTarget(), {calls:[]});
|
||||
const audioState = {muted:false,speaker:false};
|
||||
let audioQueue = Promise.resolve();
|
||||
for (const [property,key] of [['muted','muted'],['speakerEnabled','speaker']]) {
|
||||
Object.defineProperty(telephony,property,{
|
||||
get:()=>audioState[key],
|
||||
set:value=>{
|
||||
const desired = !!value, previous=audioState[key];
|
||||
if(desired===previous) return;
|
||||
audioState[key]=desired;
|
||||
audioQueue = audioQueue.then(()=>call('platform.callAudio',{operation:'set',[key]:desired}))
|
||||
.then(state=>Object.assign(audioState,state))
|
||||
.catch(error=>{audioState[key]=previous;console.error('Call audio:',error);window.VulpesGaiaUI?.notice(error.message);});
|
||||
}
|
||||
});
|
||||
}
|
||||
const unsupported = () => Promise.reject(new DOMException('Not available on this adapter', 'NotSupportedError'));
|
||||
telephony.conferenceGroup = Object.assign(new EventTarget(), {calls:[], state:'', add:unsupported, remove:unsupported, hangUp:unsupported});
|
||||
telephony.ready = Promise.resolve();
|
||||
telephony.dial = number => call('platform.dial',{number});
|
||||
telephony.dialEmergency = unsupported;
|
||||
telephony.sendTones = unsupported;
|
||||
telephony.startTone = unsupported;
|
||||
telephony.stopTone = () => {};
|
||||
Object.defineProperty(telephony,'active',{get:()=>telephony.calls.find(c=>['connected','dialing','alerting'].includes(c.state)) || null});
|
||||
Object.defineProperty(navigator,'mozTelephony',{value:telephony});
|
||||
let stopped = false;
|
||||
addEventListener('pagehide',()=>{stopped=true;},{once:true});
|
||||
const emit = (target,type,extra={}) => {
|
||||
const event = Object.assign(new Event(type),extra);
|
||||
target.dispatchEvent(event);
|
||||
try { target['on'+type]?.(event); } catch (error) { console.error('Telephony event:',error); }
|
||||
};
|
||||
async function refreshCalls() {
|
||||
try {
|
||||
const current = await call('platform.calls',{});
|
||||
if (location.hostname === 'callscreen.localhost') Object.assign(audioState,await call('platform.callAudio',{operation:'status'}));
|
||||
const live = current.filter(c=>c.state!=='disconnected');
|
||||
const old = telephony.calls;
|
||||
let changed = false;
|
||||
const changes = [];
|
||||
telephony.calls = live.map(item=>{
|
||||
let c = old.find(c=>c.path===item.path);
|
||||
const state = ({active:'connected',waiting:'incoming'})[item.state] || item.state;
|
||||
if (!c) {
|
||||
changed = true;
|
||||
c = Object.assign(new EventTarget(),{path:item.path,id:{number:item.number}, serviceId:0,
|
||||
state, emergency:false, group:null, switchable:false, mergeable:false,
|
||||
answer:()=>call('platform.answer',{path:item.path}), hangUp:()=>call('platform.hangup',{path:item.path}),
|
||||
hold:unsupported,resume:unsupported});
|
||||
} else if (c.state !== state) { c.state=state;changes.push(c); }
|
||||
return c;
|
||||
});
|
||||
for (const c of old) if (!telephony.calls.includes(c)) {c.state='disconnected';changes.push(c);changed=true;}
|
||||
for (const c of changes) {emit(c,'statechange',{call:c});emit(c,c.state,{call:c});}
|
||||
if (changed) {
|
||||
if (location.hostname === 'system.localhost' && live.some(c => ['incoming','waiting'].includes(c.state))) window.Service?.request('turnScreenOn');
|
||||
emit(telephony,'callschanged');
|
||||
}
|
||||
} catch (_) { /* Keep live calls on transport failure. */ }
|
||||
if (!stopped) setTimeout(refreshCalls, 500);
|
||||
}
|
||||
addEventListener('load', refreshCalls, {once:true});
|
||||
if (location.hostname === 'callscreen.localhost') addEventListener('load',()=>{
|
||||
// Keep Gaia's controls, but do not advertise unimplemented audio/conference operations.
|
||||
const disable = () => {
|
||||
for (const id of ['on-hold','merge','keypad-visibility','place-new-call']) {
|
||||
const node = document.getElementById(id);
|
||||
if (node && !node.disabled) node.disabled=true;
|
||||
}
|
||||
};
|
||||
new MutationObserver(disable).observe(document.body,{subtree:true,attributes:true,attributeFilter:['disabled']});
|
||||
disable();
|
||||
},{once:true});
|
||||
}
|
||||
|
||||
// Legacy contact lookup needs formatting normalization, not an inferred country code.
|
||||
Object.defineProperty(navigator, 'mozPhoneNumberService', {value:{
|
||||
normalize: number => String(number || '').normalize('NFKC').replace(/[^0-9+*#]/g, '')
|
||||
}});
|
||||
const settingsCodec = window.VulpesAndroidSettingsCodec;
|
||||
async function call(method, params) {
|
||||
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
|
||||
if (settingsCodec && ['settings.set', 'media.request'].includes(method))
|
||||
params = await settingsCodec.encode(params);
|
||||
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
|
||||
if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
|
||||
return settingsCodec &&
|
||||
['settings.get', 'settings.snapshot', 'media.request'].includes(method)
|
||||
? settingsCodec.decode(reply.result)
|
||||
: reply.result;
|
||||
});
|
||||
}
|
||||
function request(promise) {
|
||||
const r = new EventTarget();
|
||||
r.result = null;
|
||||
r.error = null;
|
||||
r.readyState = 'pending';
|
||||
r.then = promise.then.bind(promise);
|
||||
promise.then(
|
||||
(value) => {
|
||||
r.result = value;
|
||||
finish('success');
|
||||
},
|
||||
(error) => {
|
||||
r.error = error;
|
||||
finish('error');
|
||||
},
|
||||
);
|
||||
function finish(type) {
|
||||
r.readyState = 'done';
|
||||
const event = new Event(type);
|
||||
r.dispatchEvent(event);
|
||||
r['on' + type]?.call(r, { type, target: r });
|
||||
}
|
||||
return r;
|
||||
}
|
||||
const observers = new Map();
|
||||
const settings = new EventTarget();
|
||||
settings.createLock = () => ({
|
||||
get: (key) =>
|
||||
request(
|
||||
key === '*'
|
||||
? call('settings.snapshot', {})
|
||||
: call('settings.get', { key }).then((value) => ({ [key]: value.value })),
|
||||
),
|
||||
set: (values) => {
|
||||
// Legacy passcode fields are byte sequences. Keep v1 JSON transport and
|
||||
// preserve each byte; Gaia accepts numeric arrays when reading them.
|
||||
const encoded = { ...values };
|
||||
for (const key of [
|
||||
'lockscreen.passcode-lock.digest.salt',
|
||||
'lockscreen.passcode-lock.digest.value',
|
||||
]) {
|
||||
if (encoded[key] instanceof Uint8Array) encoded[key] = Array.from(encoded[key]);
|
||||
}
|
||||
return request(call('settings.set', { values: encoded }).then(() => null));
|
||||
},
|
||||
});
|
||||
settings.addObserver = (key, fn) => {
|
||||
if (!observers.has(key)) observers.set(key, new Set());
|
||||
observers.get(key).add(fn);
|
||||
};
|
||||
settings.removeObserver = (key, fn) => observers.get(key)?.delete(fn);
|
||||
Object.defineProperty(navigator, 'mozSettings', { value: settings });
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
const { type, data } = event.detail;
|
||||
if (type === 'settingchange') {
|
||||
const change = {
|
||||
settingName: data.key,
|
||||
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value,
|
||||
};
|
||||
observers.get(data.key)?.forEach((fn) => fn(change));
|
||||
settings.onsettingchange?.(change);
|
||||
}
|
||||
if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
|
||||
if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data}));
|
||||
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
|
||||
if (type === 'home' || type === 'holdhome') dispatchEvent(new CustomEvent(type));
|
||||
if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
|
||||
});
|
||||
function application(record) {
|
||||
// DOMApplication never exposed the registry's internal id. Gaia uses `id`
|
||||
// to distinguish a bookmark from an installed application.
|
||||
const { id, ...publicRecord } = record;
|
||||
const app = Object.assign(new EventTarget(), publicRecord, {
|
||||
installState: 'installed',
|
||||
removable: false,
|
||||
installTime: 0,
|
||||
updateTime: 0,
|
||||
downloadAvailable: false,
|
||||
downloading: false,
|
||||
readyToApplyDownload: false,
|
||||
});
|
||||
app.launch = (entryPoint) =>
|
||||
request(call('apps.launch', { manifestURL: app.manifestURL, entryPoint }));
|
||||
app.getLocalizedValue = (property, language, entry) => {
|
||||
const manifest = app.manifest.entry_points?.[entry] || app.manifest;
|
||||
const locales = manifest.locales || {};
|
||||
const locale = locales[language] || locales[language?.split('-')[0]] || {};
|
||||
const value = locale[property] ?? manifest[property];
|
||||
return value === undefined
|
||||
? Promise.reject(new Error('NoSuchProperty'))
|
||||
: Promise.resolve(value);
|
||||
};
|
||||
app.connect = (keyword) => window.VulpesCompat.connect(keyword);
|
||||
return app;
|
||||
}
|
||||
const mgmt = new EventTarget();
|
||||
mgmt.getAll = () => request(call('apps.list').then((apps) => apps.map(application)));
|
||||
mgmt.getNotInstalled = () => request(Promise.resolve([]));
|
||||
mgmt.getIcon = async (app, size, entry) => {
|
||||
const manifest = app.manifest.entry_points?.[entry] || app.manifest;
|
||||
const sizes = Object.keys(manifest.icons || {})
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const chosen = sizes.find((s) => s >= size) || sizes.at(-1);
|
||||
if (!chosen) throw new Error('NO_ICON');
|
||||
const response = await fetch(new URL(manifest.icons[chosen], app.origin));
|
||||
if (!response.ok) throw new Error('ICON_LOAD_ERROR');
|
||||
return response.blob();
|
||||
};
|
||||
Object.defineProperty(navigator, 'mozApps', {
|
||||
value: {
|
||||
mgmt,
|
||||
getSelf: () => request(call('apps.self').then(application)),
|
||||
getInstalled: () => request(call('apps.self').then((app) => [application(app)])),
|
||||
getAdditionalLanguages: () => Promise.resolve({}),
|
||||
},
|
||||
});
|
||||
const dataStores = new Map();
|
||||
navigator.getDataStores = (name) =>
|
||||
call('datastores.list', { name }).then((list) =>
|
||||
list.map((info) => {
|
||||
const key = info.name + '\n' + info.owner;
|
||||
if (dataStores.has(key)) return dataStores.get(key);
|
||||
const store = Object.assign(new EventTarget(), info);
|
||||
const run = (operation, args) => call('datastores.run', { ...info, operation, args });
|
||||
store.get = (...ids) => run('get', ids);
|
||||
store.put = (value, id) => run('put', [value, id]);
|
||||
store.add = (value, id) => run('add', [value, id]);
|
||||
store.remove = (id) => run('remove', [id]);
|
||||
store.clear = () => run('clear', []);
|
||||
store.getLength = () => run('length', []);
|
||||
store.sync = () => {
|
||||
let queue,
|
||||
closed = false;
|
||||
const snapshot = run('snapshot', []).then((result) => {
|
||||
store.revisionId = result.revisionId;
|
||||
queue = [{ operation: 'clear', revisionId: result.revisionId }].concat(
|
||||
result.entries.map((entry) => ({
|
||||
operation: 'add',
|
||||
id: entry.id,
|
||||
data: entry.data,
|
||||
revisionId: result.revisionId,
|
||||
})),
|
||||
[{ operation: 'done', revisionId: result.revisionId }],
|
||||
);
|
||||
});
|
||||
return {
|
||||
next: () =>
|
||||
snapshot.then(() =>
|
||||
!closed && queue.length
|
||||
? queue.shift()
|
||||
: { operation: 'done', revisionId: store.revisionId },
|
||||
),
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
dataStores.set(key, store);
|
||||
return store;
|
||||
}),
|
||||
);
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
if (event.detail.type !== 'datastorechange') return;
|
||||
const data = event.detail.data,
|
||||
store = dataStores.get(data.name + '\n' + data.owner);
|
||||
if (!store) return;
|
||||
store.revisionId = data.revisionId;
|
||||
const change = new Event('change');
|
||||
for (const name of ['id', 'operation', 'revisionId'])
|
||||
Object.defineProperty(change, name, { value: data[name] });
|
||||
store.dispatchEvent(change);
|
||||
store.onchange?.(change);
|
||||
});
|
||||
// Capability absence is explicit; do not invent modem or hardware state.
|
||||
const handlers = new Map();
|
||||
navigator.mozSetMessageHandler = (name, fn) => {
|
||||
handlers.set(name, fn);
|
||||
if (name === 'connection') call('iac.subscribe', {}).catch(console.error);
|
||||
if (name === 'alarm') call('alarms.request', { operation: 'subscribe' }).catch(console.error);
|
||||
};
|
||||
navigator.mozHasPendingMessage = () => false;
|
||||
navigator.mozSetMessageHandlerPromise = (promise) => promise;
|
||||
window.VulpesCompat = { call, request, handlers };
|
||||
window.MozActivity = function (source) {
|
||||
return request(call('activities.start', source));
|
||||
};
|
||||
const idleObservers = new Map();
|
||||
navigator.addIdleObserver = (observer) => {
|
||||
if (idleObservers.has(observer)) return;
|
||||
const token = crypto.randomUUID();
|
||||
idleObservers.set(observer, token);
|
||||
call('desktop.idle', { token, time: observer.time, add: true }).catch((error) => {
|
||||
idleObservers.delete(observer);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
navigator.removeIdleObserver = (observer) => {
|
||||
const token = idleObservers.get(observer);
|
||||
if (!token) return;
|
||||
idleObservers.delete(observer);
|
||||
call('desktop.idle', { token, time: observer.time, add: false }).catch(console.error);
|
||||
};
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
if (event.detail.type !== 'idle') return;
|
||||
for (const [observer, token] of idleObservers) {
|
||||
if (token === event.detail.data.token)
|
||||
observer[event.detail.data.idle ? 'onidle' : 'onactive']?.();
|
||||
}
|
||||
});
|
||||
navigator.requestWakeLock = (topic) => {
|
||||
const token = crypto.randomUUID();
|
||||
let unlocked = false;
|
||||
const acquired = call('desktop.wakelock', { topic, token, acquire: true });
|
||||
return {
|
||||
topic,
|
||||
unlock() {
|
||||
if (unlocked) throw new DOMException('Lock already released', 'InvalidStateError');
|
||||
unlocked = true;
|
||||
acquired
|
||||
.then(() => call('desktop.wakelock', { topic, token, acquire: false }))
|
||||
.catch(console.error);
|
||||
},
|
||||
};
|
||||
};
|
||||
if (location.hostname === 'system.localhost') {
|
||||
// Virtual screen of the desktop host, not the physical monitor's power API.
|
||||
const listeners = new Set(),
|
||||
locks = new Map();
|
||||
const power = {
|
||||
powerOff: () => call('platform.power', {action:'poweroff'}).catch(console.error),
|
||||
reboot: () => call('platform.power', {action:'reboot'}).catch(console.error),
|
||||
addWakeLockListener: (fn) => listeners.add(fn),
|
||||
removeWakeLockListener: (fn) => listeners.delete(fn),
|
||||
getWakeLockState: (topic) => locks.get(topic) || 'unlocked',
|
||||
};
|
||||
for (const [name, initial] of Object.entries({
|
||||
screenEnabled: true,
|
||||
screenBrightness: 1,
|
||||
keyLightEnabled: false,
|
||||
cpuSleepAllowed: true,
|
||||
})) {
|
||||
let value = initial;
|
||||
Object.defineProperty(power, name, {
|
||||
get: () => value,
|
||||
set(next) {
|
||||
if (name === 'screenBrightness') {
|
||||
next = Number(next);
|
||||
if (!Number.isFinite(next) || next < 0 || next > 1) return;
|
||||
}
|
||||
value = next;
|
||||
call('desktop.screen', { name, value: next }).catch(console.error);
|
||||
},
|
||||
});
|
||||
}
|
||||
Object.defineProperty(navigator, 'mozPower', { value: power });
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
if (event.detail.type !== 'wakelockchange') return;
|
||||
const { topic, state } = event.detail.data;
|
||||
locks.set(topic, state);
|
||||
for (const listener of listeners) listener.callback(topic, state);
|
||||
});
|
||||
}
|
||||
window.addEventListener(
|
||||
'load',
|
||||
() =>
|
||||
dispatchEvent(
|
||||
new CustomEvent('mozChromeEvent', { detail: { type: 'webapps-registry-ready' } }),
|
||||
),
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// App frames remain ordinary cross-origin, sandboxed browsing contexts.
|
||||
// History, captures and external sites use the narrowly scoped native adapter.
|
||||
const frames = new WeakSet();
|
||||
const webViews = new Map();
|
||||
const nativeSource = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src');
|
||||
async function viewCall(frame, params) {
|
||||
const reply = await navigator.vulpesFrameRequest(frame, params);
|
||||
if (reply.error) throw new Error(reply.error.code);
|
||||
return reply.result;
|
||||
}
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, 'src', {
|
||||
get() {
|
||||
return webViews.get(this)?.url || nativeSource.get.call(this);
|
||||
},
|
||||
set(value) {
|
||||
const url = new URL(value, location.href);
|
||||
const external =
|
||||
['http:', 'https:'].includes(url.protocol) &&
|
||||
!(url.hostname.endsWith('.localhost') && url.port === location.port);
|
||||
if (this.hasAttribute('mozbrowser') && external) {
|
||||
const entry = webViews.get(this) || { opened: false, busy: false };
|
||||
entry.url = url.href;
|
||||
entry.dirty = true;
|
||||
webViews.set(this, entry);
|
||||
if (!entry.opened) nativeSource.set.call(this, 'about:blank');
|
||||
} else nativeSource.set.call(this, value);
|
||||
},
|
||||
});
|
||||
setInterval(() => {
|
||||
for (const [frame, entry] of webViews) {
|
||||
if (entry.opened && !frame.isConnected) {
|
||||
webViews.delete(frame);
|
||||
continue;
|
||||
}
|
||||
if (!frame.isConnected || entry.busy) continue;
|
||||
entry.busy = true;
|
||||
(async () => {
|
||||
if (entry.dirty) {
|
||||
await viewCall(frame, { operation: 'open', url: entry.url });
|
||||
entry.dirty = false;
|
||||
entry.opened = true;
|
||||
}
|
||||
await viewCall(frame, { operation: 'geometry' });
|
||||
})()
|
||||
.catch((error) => {
|
||||
if (!entry.failed) console.error('External view:', error);
|
||||
entry.failed = true;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.busy = false;
|
||||
});
|
||||
}
|
||||
}, 120);
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
if (event.detail.type !== 'view') return;
|
||||
const data = event.detail.data;
|
||||
// The child adapter attaches the originating frame itself, not a caller's id.
|
||||
const frame = navigator.vulpesFrameForContext(data.contextId);
|
||||
if (!frame || !webViews.has(frame)) return;
|
||||
if (data.type === 'locationchange') webViews.get(frame).url = data.detail.url;
|
||||
frame.dispatchEvent(
|
||||
new CustomEvent('mozbrowser' + data.type, { bubbles: true, detail: data.detail }),
|
||||
);
|
||||
});
|
||||
function wire(frame) {
|
||||
if (frames.has(frame)) return;
|
||||
frames.add(frame);
|
||||
// Set delegation before navigation; a MutationObserver runs too late on Android.
|
||||
if (location.hostname === 'system.localhost')
|
||||
frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port);
|
||||
frame.addEventListener('load', () => {
|
||||
if (!frame.hasAttribute('mozbrowser')) return;
|
||||
if (webViews.has(frame)) return;
|
||||
frame.dispatchEvent(
|
||||
new CustomEvent('mozbrowserloadend', { bubbles: true, detail: { background: false } }),
|
||||
);
|
||||
frame.dispatchEvent(new CustomEvent('mozbrowserfirstpaint', { bubbles: true }));
|
||||
frame.dispatchEvent(new CustomEvent('mozbrowserdocumentfirstpaint', { bubbles: true }));
|
||||
frame.dispatchEvent(
|
||||
new CustomEvent('mozbrowservisibilitychange', {
|
||||
bubbles: true,
|
||||
detail: { visible: !frame.hidden },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
const iframe = HTMLIFrameElement.prototype;
|
||||
function forwardInput(frame, input) {
|
||||
const target=new URL(frame.src,location.href);
|
||||
if(location.hostname!=='system.localhost' || !/^http:\/\/[a-z0-9_-]+\.localhost:\d+$/.test(target.origin))
|
||||
throw new DOMException('Input forwarding is limited to Gaia applications','SecurityError');
|
||||
frame.contentWindow?.postMessage({type:'vulpes-forward-input',input},target.origin);
|
||||
}
|
||||
iframe.sendTouchEvent = function (type, ids=[], xs=[], ys=[], rx=[], ry=[], rotation=[], force=[]) {
|
||||
if(type==='touchcancel') {viewCall(this,{operation:'cancelTouch'}).catch(console.error);return;}
|
||||
if(!['touchstart','touchmove','touchend'].includes(type)) throw new TypeError('Invalid touch type');
|
||||
const rect=this.getBoundingClientRect();
|
||||
forwardInput(this,{type,points:ids.slice(0,10).map((id,i)=>({id,x:xs[i]-rect.x,y:ys[i]-rect.y,
|
||||
rx:rx[i],ry:ry[i],rotation:rotation[i],force:force[i]}))});
|
||||
};
|
||||
iframe.sendMouseEvent = function(type,x,y) {
|
||||
if(!['mousemove','mousedown','mouseup'].includes(type)) throw new TypeError('Invalid mouse type');
|
||||
const rect=this.getBoundingClientRect();forwardInput(this,{type,x:x-rect.x,y:y-rect.y});
|
||||
};
|
||||
// These are ordinary, untrusted DOM events. Forwarding never grants a user
|
||||
// activation or permission to an external Web page.
|
||||
const forwardedTouches=new Map();
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==parent || event.origin!=='http://system.localhost:'+location.port ||
|
||||
event.data?.type!=='vulpes-forward-input') return;
|
||||
const input=event.data.input;
|
||||
if(['mousemove','mousedown','mouseup'].includes(input?.type)) {
|
||||
if(!Number.isFinite(input.x)||!Number.isFinite(input.y)) return;
|
||||
const target=document.elementFromPoint(input.x,input.y);
|
||||
const props={bubbles:true,cancelable:true,clientX:input.x,clientY:input.y,button:0};
|
||||
target?.dispatchEvent(new MouseEvent(input.type,props));
|
||||
if(input.type==='mouseup') target?.dispatchEvent(new MouseEvent('click',props));
|
||||
return;
|
||||
}
|
||||
if(!['touchstart','touchmove','touchend'].includes(input?.type)||!Array.isArray(input.points)||input.points.length>10) return;
|
||||
const changed=[];
|
||||
for(const p of input.points) {
|
||||
if(![p.id,p.x,p.y].every(Number.isFinite)) return;
|
||||
const target=forwardedTouches.get(p.id)?.target || document.elementFromPoint(p.x,p.y);
|
||||
if(!target) continue;
|
||||
const touch=new Touch({identifier:p.id,target,clientX:p.x,clientY:p.y,pageX:p.x+scrollX,pageY:p.y+scrollY,
|
||||
radiusX:p.rx||1,radiusY:p.ry||1,rotationAngle:p.rotation||0,force:p.force||0});
|
||||
changed.push(touch);
|
||||
if(input.type==='touchend') forwardedTouches.delete(p.id);else forwardedTouches.set(p.id,touch);
|
||||
}
|
||||
const active=[...forwardedTouches.values()];
|
||||
for(const target of new Set(changed.map(t=>t.target))) target.dispatchEvent(new TouchEvent(input.type,{
|
||||
bubbles:true,cancelable:true,touches:active,targetTouches:active.filter(t=>t.target===target),
|
||||
changedTouches:changed.filter(t=>t.target===target)}));
|
||||
});
|
||||
iframe.setVisible = function (value) {
|
||||
this.hidden = !value;
|
||||
if (location.hostname === 'system.localhost' &&
|
||||
this.src.startsWith('http://camera.localhost:' + location.port + '/'))
|
||||
this.contentWindow?.postMessage(
|
||||
{ type: 'vulpes-camera-visibility', visible: !!value },
|
||||
'http://camera.localhost:' + location.port,
|
||||
);
|
||||
};
|
||||
iframe.getVisible = function () {
|
||||
return request(Promise.resolve(!this.hidden));
|
||||
};
|
||||
iframe.setActive = function (value) {
|
||||
this.inert = !value;
|
||||
};
|
||||
iframe.getActive = function () {
|
||||
return !this.inert;
|
||||
};
|
||||
for (const [name, operation] of Object.entries({
|
||||
goBack: 'back',
|
||||
goForward: 'forward',
|
||||
reload: 'reload',
|
||||
stop: 'stop',
|
||||
})) {
|
||||
iframe[name] = function () {
|
||||
return viewCall(this, { operation }).catch(console.error);
|
||||
};
|
||||
}
|
||||
const paints = new WeakMap();
|
||||
iframe.addNextPaintListener = function (callback) {
|
||||
let callbacks = paints.get(this);
|
||||
if (!callbacks) {
|
||||
callbacks = new Map();
|
||||
paints.set(this, callbacks);
|
||||
}
|
||||
if (callbacks.has(callback)) return;
|
||||
const first = requestAnimationFrame(() => {
|
||||
const second = requestAnimationFrame((time) => {
|
||||
callbacks.delete(callback);
|
||||
callback(time);
|
||||
});
|
||||
callbacks.set(callback, second);
|
||||
});
|
||||
callbacks.set(callback, first);
|
||||
};
|
||||
iframe.removeNextPaintListener = function (callback) {
|
||||
const callbacks = paints.get(this);
|
||||
if (callbacks?.has(callback)) {
|
||||
cancelAnimationFrame(callbacks.get(callback));
|
||||
callbacks.delete(callback);
|
||||
}
|
||||
};
|
||||
for (const [name, operation] of Object.entries({
|
||||
getCanGoBack: 'canGoBack',
|
||||
getCanGoForward: 'canGoForward',
|
||||
})) {
|
||||
iframe[name] = function () {
|
||||
return request(viewCall(this, { operation }));
|
||||
};
|
||||
}
|
||||
iframe.getScreenshot = function () {
|
||||
return request(viewCall(this, { operation: 'capture' }));
|
||||
};
|
||||
for (const name of ['setInputMethodActive', 'getWebManifest']) {
|
||||
iframe[name] = function () {
|
||||
return request(
|
||||
Promise.reject(new DOMException(name + ' is not ported yet', 'NotSupportedError')),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// v0 custom-element lifecycle compatibility, implemented on v1. Kept in one
|
||||
// module so Gaia components can subsequently migrate individually.
|
||||
const nativeCreate = document.createElement.bind(document);
|
||||
const initialize = (element) => {
|
||||
if (!element.__vulpesCreated) {
|
||||
element.__vulpesCreated = true;
|
||||
element.createdCallback?.();
|
||||
if (element.attributeChangedCallback)
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
const value = element.getAttribute(record.attributeName);
|
||||
if (record.oldValue !== value)
|
||||
element.attributeChangedCallback(record.attributeName, record.oldValue, value);
|
||||
}
|
||||
}).observe(element, { attributes: true, attributeOldValue: true });
|
||||
}
|
||||
return element;
|
||||
};
|
||||
const registered = new Set();
|
||||
document.registerElement = (name, options) => {
|
||||
if (options.extends)
|
||||
throw new DOMException(
|
||||
'Customized built-in requires component migration',
|
||||
'NotSupportedError',
|
||||
);
|
||||
const prototype = options.prototype;
|
||||
class GaiaElement extends HTMLElement {
|
||||
connectedCallback() {
|
||||
initialize(this);
|
||||
this.attachedCallback?.();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
this.detachedCallback?.();
|
||||
}
|
||||
}
|
||||
Object.setPrototypeOf(GaiaElement.prototype, prototype);
|
||||
customElements.define(name, GaiaElement);
|
||||
registered.add(name);
|
||||
return GaiaElement;
|
||||
};
|
||||
document.createElement = function (name, options) {
|
||||
const element = nativeCreate(name, options);
|
||||
if (name.toLowerCase() === 'iframe') wire(element);
|
||||
return registered.has(name) ? initialize(element) : element;
|
||||
};
|
||||
HTMLElement.prototype.createShadowRoot = function () {
|
||||
const host = this,
|
||||
root = this.attachShadow({ mode: 'open' });
|
||||
const nativeHTML = Object.getOwnPropertyDescriptor(ShadowRoot.prototype, 'innerHTML');
|
||||
const distribute = () => {
|
||||
const points = Array.from(root.querySelectorAll('content'));
|
||||
let index = 0;
|
||||
for (const point of points) {
|
||||
if (!point.querySelector('slot')) {
|
||||
const slot = nativeCreate('slot');
|
||||
slot.name =
|
||||
!point.getAttribute('select') || point.getAttribute('select') === '*'
|
||||
? ''
|
||||
: 'gaia-slot-' + index;
|
||||
point.style.display = 'contents';
|
||||
point.append(slot);
|
||||
point.getDistributedNodes = () => slot.assignedNodes();
|
||||
}
|
||||
index++;
|
||||
}
|
||||
for (const child of Object.getOwnPropertyDescriptor(Element.prototype, 'children').get.call(
|
||||
host,
|
||||
)) {
|
||||
const point = points.find(
|
||||
(p) => !p.getAttribute('select') || child.matches(p.getAttribute('select')),
|
||||
);
|
||||
if (point) child.slot = point.querySelector('slot').name;
|
||||
}
|
||||
};
|
||||
Object.defineProperty(root, 'innerHTML', {
|
||||
get() {
|
||||
return nativeHTML.get.call(root);
|
||||
},
|
||||
set(value) {
|
||||
nativeHTML.set.call(root, value);
|
||||
let index = 0;
|
||||
for (const point of root.querySelectorAll('content')) {
|
||||
const slot = nativeCreate('slot');
|
||||
slot.name =
|
||||
!point.getAttribute('select') || point.getAttribute('select') === '*'
|
||||
? ''
|
||||
: 'gaia-slot-' + index;
|
||||
index++;
|
||||
point.style.display = 'contents';
|
||||
point.append(slot);
|
||||
point.getDistributedNodes = () => slot.assignedNodes();
|
||||
}
|
||||
distribute();
|
||||
},
|
||||
});
|
||||
root.appendChild = function (node) {
|
||||
const value = Node.prototype.appendChild.call(root, node);
|
||||
distribute();
|
||||
return value;
|
||||
};
|
||||
new MutationObserver(distribute).observe(host, { childList: true });
|
||||
new MutationObserver(distribute).observe(root, { childList: true, subtree: true });
|
||||
return root;
|
||||
};
|
||||
for (const name of [
|
||||
'slice',
|
||||
'forEach',
|
||||
'map',
|
||||
'filter',
|
||||
'indexOf',
|
||||
'some',
|
||||
'every',
|
||||
'reduce',
|
||||
'reduceRight',
|
||||
]) {
|
||||
if (!Array[name]) Array[name] = (array, ...args) => Array.prototype[name].call(array, ...args);
|
||||
}
|
||||
if (!String.prototype.contains) String.prototype.contains = String.prototype.includes;
|
||||
window.addEventListener('error', (event) =>
|
||||
console.error('VULPES GAIA', event.message, event.filename, event.lineno),
|
||||
);
|
||||
})();
|
||||
96
host/gaia-input.js
Normal file
96
host/gaia-input.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Keep Gaia's keyboard and layouts; replace its retired Gecko input-method API.
|
||||
(() => {
|
||||
if (!window.VulpesNativeInput) return;
|
||||
const app=location.hostname.split('.')[0], {call}=VulpesCompat;
|
||||
if (app==='keyboard') {
|
||||
let viewport, pendingState;
|
||||
// The keyboard iframe is short even when the phone is in portrait mode.
|
||||
// Gaia's scale must follow the containing screen, not that iframe's aspect ratio.
|
||||
addEventListener('message', event => {
|
||||
if (event.source !== parent || event.origin !== 'http://system.localhost:8765' ||
|
||||
event.data?.type !== 'vulpes-keyboard-viewport') return;
|
||||
const {width,height} = event.data;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width<=0 || height<=0) return;
|
||||
if (viewport?.width===width && viewport?.height===height) return;
|
||||
viewport={width,height};
|
||||
dispatchEvent(new Event('resize'));
|
||||
if(pendingState!==undefined) {setState(pendingState);pendingState=undefined;}
|
||||
});
|
||||
addEventListener('DOMContentLoaded', () => {
|
||||
ViewManager.prototype.screenInPortraitMode = function() {
|
||||
return viewport ? viewport.width<=viewport.height :
|
||||
this.cachedWindowWidth<=this.cachedWindowHeight;
|
||||
};
|
||||
parent.postMessage({type:'vulpes-keyboard-ready'},'http://system.localhost:8765');
|
||||
}, {once:true});
|
||||
const ime=Object.assign(new EventTarget(),{inputcontext:null,mgmt:{
|
||||
supportsSwitching:()=>false,
|
||||
hide:()=>call('input.request',{operation:'hide'}),
|
||||
next:()=>{},showAll:()=>{}
|
||||
}});
|
||||
const setState=state=>{
|
||||
if(!viewport) {pendingState=state;return;}
|
||||
if (!state) ime.inputcontext=null;
|
||||
else {
|
||||
const ctx=Object.assign(new EventTarget(),state);
|
||||
const edit=(operation,params={})=>call('input.request',{operation,id:ctx.id,...params}).then(next=>{
|
||||
Object.assign(ctx,next);
|
||||
ctx.dispatchEvent(new CustomEvent('selectionchange',{detail:{ownAction:true}}));
|
||||
ctx.dispatchEvent(new CustomEvent('surroundingtextchange',{detail:{ownAction:true}}));
|
||||
return next;
|
||||
});
|
||||
ctx.getText=(offset=0,length)=>Promise.resolve(ctx.text.slice(offset,length===undefined?undefined:offset+length));
|
||||
ctx.sendKey=(key,charCode)=>edit('key',{key, charCode:charCode||0});
|
||||
ctx.setSelectionRange=(start,length)=>edit('selection',{start,length});
|
||||
ctx.replaceSurroundingText=(text,offset,length)=>edit('replace',{text,offset,length});
|
||||
ctx.setComposition=()=>Promise.resolve();
|
||||
ctx.endComposition=text=>edit('replace',{text:text||'',offset:0,length:0});
|
||||
ime.inputcontext=ctx;
|
||||
}
|
||||
ime.dispatchEvent(new Event('inputcontextchange'));
|
||||
};
|
||||
Object.defineProperty(navigator,'mozInputMethod',{value:ime});
|
||||
addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);});
|
||||
call('input.request',{operation:'state'}).then(setState).catch(console.error);
|
||||
window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
|
||||
}
|
||||
if(app==='system') {
|
||||
let frame, height=0;
|
||||
const sendViewport=()=>frame?.contentWindow.postMessage({
|
||||
type:'vulpes-keyboard-viewport',width:innerWidth,height:innerHeight
|
||||
},'http://keyboard.localhost:8765');
|
||||
addEventListener('resize',sendViewport);
|
||||
addEventListener('message',event=>{
|
||||
if(event.source===frame?.contentWindow && event.origin==='http://keyboard.localhost:8765' &&
|
||||
event.data?.type==='vulpes-keyboard-ready') sendViewport();
|
||||
});
|
||||
const resize=next=>{
|
||||
if(next===height) return;
|
||||
height=next;
|
||||
if(frame) {frame.style.height=height+'px';frame.hidden=!height;}
|
||||
dispatchEvent(new CustomEvent(height?'keyboardchange':'keyboardhide',{detail:{height,waitUntil:()=>{}}}));
|
||||
};
|
||||
const attach=setInterval(()=>{
|
||||
if(!window.Service) return;
|
||||
clearInterval(attach);
|
||||
Service.registerState('getHeight',{name:'InputWindowManager',getHeight:()=>height});
|
||||
},100);
|
||||
addEventListener('vulpes-service-event',({detail})=>{
|
||||
if(detail.type==='input-height') {if(frame && !frame.hidden) resize(detail.data.height);return;}
|
||||
if(detail.type!=='input-focus') return;
|
||||
if(!detail.data) {resize(0);return;}
|
||||
if(!frame) {
|
||||
frame=document.createElement('iframe');frame.id='vulpes-gaia-keyboard';
|
||||
frame.title='Keyboard';frame.style.cssText='position:fixed;bottom:0;left:0;width:100%;border:0;z-index:10000;background:#ddd';
|
||||
frame.addEventListener('load',sendViewport);
|
||||
frame.src='http://keyboard.localhost:8765/index.html#'+
|
||||
((document.documentElement.lang || navigator.language).startsWith('fr')?'fr':'en');
|
||||
document.body.append(frame);
|
||||
}
|
||||
frame.hidden=false;
|
||||
resize(height || 260);
|
||||
});
|
||||
for(const type of ['home','lockscreen-appopened','appwillclose'])
|
||||
addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error));
|
||||
}
|
||||
})();
|
||||
220
host/gaia-restoration.js
Normal file
220
host/gaia-restoration.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Restore Gaia views without claiming unavailable host APIs are implemented.
|
||||
(() => {
|
||||
const app = location.hostname.split('.')[0];
|
||||
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
|
||||
const label = (a, b) => fr() ? a : b;
|
||||
function notice(message) {
|
||||
document.getElementById('vulpes-gaia-notice')?.remove();
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet'; link.href = '/shared/style/confirm.css';
|
||||
if (!document.querySelector('link[href="/shared/style/confirm.css"]')) document.head.append(link);
|
||||
// Original Gaia confirmation building block and stylesheet.
|
||||
const form = document.createElement('form');
|
||||
form.id = 'vulpes-gaia-notice';
|
||||
form.setAttribute('role', 'dialog'); form.dataset.type = 'confirm';
|
||||
form.setAttribute('aria-modal', 'true');
|
||||
const section = document.createElement('section'), p = document.createElement('p');
|
||||
p.textContent = message; section.append(p);
|
||||
const menu = document.createElement('menu'), close = document.createElement('button');
|
||||
close.type = 'submit'; close.textContent = label('Fermer', 'Close');
|
||||
menu.append(close); form.append(section, menu);
|
||||
const previous = document.activeElement;
|
||||
form.onsubmit = event => { event.preventDefault(); form.remove(); previous?.focus(); };
|
||||
form.onkeydown = event => { if (event.key === 'Escape') { event.preventDefault(); form.requestSubmit(); } };
|
||||
document.body.append(form); close.focus();
|
||||
}
|
||||
const unavailable = () => label('Cette fonction n’est pas encore disponible sur cette plateforme.',
|
||||
'This feature is not available on this platform yet.');
|
||||
window.VulpesGaiaUI = {notice};
|
||||
async function ready() {
|
||||
if (app === 'system') {
|
||||
// OperatorIcon also owns the date. It must exist even without a modem.
|
||||
const startDate = async () => {
|
||||
if (!window.BaseIcon || !window.Service || !window.LazyLoader) return false;
|
||||
if (document.getElementById('statusbar-operator')) return true;
|
||||
await LazyLoader.load(['js/operator_icon.js']);
|
||||
const date = new OperatorIcon({mobileConnections:[]});
|
||||
date.start();
|
||||
setInterval(() => date.update(), 30000);
|
||||
for (const name of ['localized', 'moztimechange', 'visibilitychange'])
|
||||
addEventListener(name, () => date.update());
|
||||
return true;
|
||||
};
|
||||
const timer = setInterval(() => startDate().then(done => {if(done) clearInterval(timer);}).catch(console.error), 500);
|
||||
}
|
||||
if (app === 'communications' && location.pathname.startsWith('/contacts')) {
|
||||
const restoreImport = () => {
|
||||
const list = document.getElementById('import-options');
|
||||
if (list && !navigator.mozIccManager && !list.querySelector('[id^="import-sim-option-"]')) {
|
||||
// Same structure as SimDomGenerator.generateImportDOM; no fictitious SIM.
|
||||
const li = document.createElement('li'), button = document.createElement('button'), error = document.createElement('p');
|
||||
li.id = 'import-sim-option-unavailable';li.setAttribute('role','presentation');
|
||||
button.className = 'icon icon-sim';button.setAttribute('role','option');button.disabled = true;
|
||||
button.textContent = label('Carte SIM', 'SIM card');
|
||||
error.className = 'error-message';error.textContent = unavailable();
|
||||
li.append(button,error);list.prepend(li);
|
||||
}
|
||||
const item = document.getElementById('import-sd-option');
|
||||
if (!item) return;
|
||||
const button = item.querySelector('button');
|
||||
if(button?.hasAttribute('disabled')) button.removeAttribute('disabled');
|
||||
if(item.getAttribute('aria-disabled') === 'true') item.setAttribute('aria-disabled','false');
|
||||
if(item.classList.contains('disabled')) item.classList.remove('disabled');
|
||||
const span = item.querySelector('span');
|
||||
if(span && span.dataset.l10nId === 'importMemoryCard') {
|
||||
span.removeAttribute('data-l10n-id');span.textContent = label('Fichier de contacts', 'Contact file');
|
||||
}
|
||||
const error = item.querySelector('p');
|
||||
if(error && !error.hidden) error.hidden = true;
|
||||
};
|
||||
new MutationObserver(restoreImport).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['disabled','aria-disabled','class']});
|
||||
restoreImport();
|
||||
// Reuse Gaia's import-from-storage action instead of a floating button.
|
||||
document.addEventListener('click', event => {
|
||||
if (!event.target.closest('#import-sd-option')) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
VulpesCompat.call('platform.contact', {}).catch(error => {
|
||||
if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable());
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
if (app === 'sms' && window.VulpesPlatform === 'android') {
|
||||
document.addEventListener('click', event => {
|
||||
if (!event.target.closest('#messages-send-button')) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
// Android owns final confirmation and delivery. Do not fabricate a sent SMS.
|
||||
VulpesCompat.call('platform.sms', {
|
||||
number:window.ThreadUI?.recipients?.numbers?.join(';') || '',
|
||||
body:window.Compose?.getText?.() || ''
|
||||
}).catch(() => notice(unavailable()));
|
||||
}, true);
|
||||
}
|
||||
const mediaType = {gallery:'pictures', music:'music', video:'videos'}[app];
|
||||
if (mediaType && ['/', '/index.html'].includes(location.pathname)) {
|
||||
const addImport = () => {
|
||||
const header = document.querySelector('gaia-header');
|
||||
if (!header || header.querySelector('[data-vulpes-import]')) return;
|
||||
const action = document.createElement('button');
|
||||
action.dataset.vulpesImport = ''; action.dataset.icon = 'add';
|
||||
action.setAttribute('aria-label', label('Importer des fichiers', 'Import files'));
|
||||
action.onclick = () => VulpesCompat.call('platform.pick',{type:mediaType}).catch(error => {
|
||||
if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable());
|
||||
});
|
||||
header.append(action);
|
||||
};
|
||||
new MutationObserver(addImport).observe(document.body,{childList:true,subtree:true});
|
||||
addImport();
|
||||
}
|
||||
if (app !== 'settings') return;
|
||||
const capabilities = await VulpesCompat.call('platform.capabilities', {});
|
||||
async function batteryText() {
|
||||
const level = navigator.battery?.level;
|
||||
const known = Number.isFinite(level);
|
||||
for (const [selector, id] of [['#battery-desc', 'battery-level-percent'], ['#battery-level', 'battery-current-level']]) {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) continue;
|
||||
const value = known && document.l10n ? await document.l10n.formatValue(id, {level:Math.round(level * 100)}) : label('Indisponible', 'Unavailable');
|
||||
node.removeAttribute('data-l10n-id'); node.removeAttribute('data-l10n-args');
|
||||
if (node.textContent !== value) node.textContent = value;
|
||||
if (node.hidden) node.hidden = false;
|
||||
}
|
||||
}
|
||||
navigator.battery?.addEventListener('levelchange', batteryText);
|
||||
addEventListener('localized', batteryText);
|
||||
document.addEventListener('panelready', batteryText);
|
||||
batteryText();
|
||||
|
||||
const unsupported = {
|
||||
wifi: !navigator.mozWifiManager,
|
||||
bluetooth: !navigator.mozBluetooth,
|
||||
carrier: !navigator.mozMobileConnections,
|
||||
call: !navigator.mozMobileConnections,
|
||||
messaging: !navigator.mozMobileConnections,
|
||||
'sim-manager': !navigator.mozIccManager,
|
||||
simpin: !navigator.mozIccManager,
|
||||
hotspot: !navigator.mozWifiManager,
|
||||
findmydevice: true,
|
||||
fxa: true,
|
||||
fxsync: true,
|
||||
addons: true,
|
||||
mediaStorage: !navigator.getDeviceStorages,
|
||||
applicationStorage: !navigator.getDeviceStorage,
|
||||
usbStorage: !navigator.getDeviceStorages,
|
||||
};
|
||||
function preservePanel(panel) {
|
||||
const content = panel.querySelector(':scope > div');
|
||||
if (!content || content.querySelector('[data-vulpes-unavailable]')) return;
|
||||
const explanation = document.createElement('p');
|
||||
explanation.className = 'explanation'; explanation.dataset.vulpesUnavailable = '';
|
||||
explanation.setAttribute('role', 'status'); explanation.textContent = unavailable();
|
||||
content.prepend(explanation);
|
||||
for (const control of content.querySelectorAll('button,input,select,gaia-switch,gaia-checkbox,gaia-radio')) {
|
||||
control.disabled = true; control.setAttribute('disabled', '');
|
||||
}
|
||||
for (const anchor of content.querySelectorAll('a')) {
|
||||
anchor.setAttribute('aria-disabled', 'true');
|
||||
anchor.addEventListener('click', event => {event.preventDefault();event.stopImmediatePropagation();}, true);
|
||||
}
|
||||
}
|
||||
// The panel's original markup is still loaded by SettingsService. Only the
|
||||
// unavailable controller is replaced, so it cannot dereference a missing API.
|
||||
const install = setInterval(() => {
|
||||
if (typeof window.require !== 'function' || !require.defined?.('modules/settings_panel') || !require.defined?.('modules/panel_cache')) return;
|
||||
clearInterval(install);
|
||||
require(['modules/panel_cache', 'modules/settings_panel'], (cache, SettingsPanel) => {
|
||||
const original = cache.get.bind(cache), panels = new Map();
|
||||
cache.get = (id, callback) => {
|
||||
if (!unsupported[id]) return original(id, callback);
|
||||
if (!panels.has(id)) panels.set(id, SettingsPanel({onInit:preservePanel, onBeforeShow:preservePanel}));
|
||||
callback?.(panels.get(id));
|
||||
};
|
||||
});
|
||||
}, 100);
|
||||
function restore() {
|
||||
batteryText();
|
||||
const root = document.querySelector('#root');
|
||||
if (root) for (const id of ['wifi','bluetooth','carrier','call','messaging','sim-manager','simpin',
|
||||
'findmydevice','addons','mediaStorage','applicationStorage']) {
|
||||
const li = root.querySelector('a[href="#' + id + '"]')?.closest('li');
|
||||
if (li?.hidden) li.hidden = false;
|
||||
}
|
||||
if (capabilities.platform === 'android' && root && !document.getElementById('vulpes-android-settings')) {
|
||||
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
|
||||
a.id = 'vulpes-android-settings'; a.className = 'menu-item'; a.href = '#';
|
||||
a.textContent = label('Paramètres Android', 'Android settings');
|
||||
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'root'}).catch(() => notice(unavailable()));};
|
||||
li.append(a); ul.append(li); root.querySelector(':scope > div')?.append(ul);
|
||||
}
|
||||
|
||||
if (capabilities.platform === 'android') {
|
||||
const homescreens = document.querySelector('#homescreens > div');
|
||||
if (homescreens && !document.getElementById('vulpes-default-home')) {
|
||||
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
|
||||
a.id = 'vulpes-default-home'; a.className = 'menu-item'; a.href = '#';
|
||||
a.textContent = label('Accueil Android par défaut', 'Default Android home');
|
||||
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'home'}).catch(() => notice(unavailable()));};
|
||||
li.append(a);ul.append(li);homescreens.append(ul);
|
||||
}
|
||||
}
|
||||
if (window.VulpesNativeWifi) {
|
||||
// WPS remains unavailable; radio power uses the native broker.
|
||||
for (const selector of ['#wifi .wps-column', '#wifi .wps-column a']) {
|
||||
const n = document.querySelector(selector);
|
||||
if(n) {if(n.getAttribute('aria-disabled') !== 'true') n.setAttribute('aria-disabled','true'); if(n.tagName === 'GAIA-SWITCH' && !n.hasAttribute('disabled')) n.setAttribute('disabled','');}
|
||||
}
|
||||
}
|
||||
const legal = document.querySelector('#about-legal > div');
|
||||
if (legal && !document.getElementById('vulpes-credits')) {
|
||||
const p = document.createElement('p'); p.id = 'vulpes-credits'; p.className = 'explanation';
|
||||
p.textContent = label('Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.',
|
||||
'Vulpes OS builds on Mozilla Gaia and Gecko and the work of their contributors. Capyloon is among the projects inspiring this continuation. Original licenses and attributions are retained.');
|
||||
legal.append(p);
|
||||
}
|
||||
}
|
||||
new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden']});
|
||||
restore();
|
||||
}
|
||||
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => ready().catch(console.error), {once:true});
|
||||
else ready().catch(console.error);
|
||||
})();
|
||||
434
host/gaia-services.js
Normal file
434
host/gaia-services.js
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
// Content-side legacy interfaces; every operation is authorized by the host.
|
||||
(() => {
|
||||
const { call, request, handlers } = window.VulpesCompat;
|
||||
document.documentElement.dataset.vulpesApp = location.hostname.split('.')[0];
|
||||
if(window.VulpesPlatform==='tundra' && location.hostname==='system.localhost') {
|
||||
Object.defineProperty(navigator,'vibrate',{value:pattern=>{
|
||||
pattern=Array.isArray(pattern)?pattern:[pattern];
|
||||
if(pattern.length>32 || pattern.some(n=>!Number.isInteger(n) || n<0 || n>10000) || pattern.reduce((a,b)=>a+b,0)>10000) return false;
|
||||
call('hardware.vibrate',{pattern}).catch(error=>console.warn('Vibration:',error.message));
|
||||
return true;
|
||||
}});
|
||||
} else if (!navigator.vibrate) Object.defineProperty(navigator, 'vibrate', { value: () => false });
|
||||
if (window.Notification && !Notification.get)
|
||||
Notification.get = async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return (await Promise.all(registrations.map((r) => r.getNotifications()))).flat();
|
||||
};
|
||||
function unavailable(message) {
|
||||
window.VulpesGaiaUI?.notice(message);
|
||||
}
|
||||
addEventListener('DOMContentLoaded', () => {
|
||||
if (location.hostname === 'system.localhost' && !window.VulpesPlatform) {
|
||||
const ids = ['wifi', 'bluetooth', 'data', 'airplane-mode'];
|
||||
for (const id of ids) {
|
||||
const e = document.getElementById('quick-settings-' + id);
|
||||
if (!e) continue;
|
||||
e.setAttribute('aria-disabled', 'true');
|
||||
e.title = 'Connexion gérée par le système hôte';
|
||||
e.style.cssText += ';display:block;color:#aaa;opacity:.6';
|
||||
e.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
unavailable(
|
||||
'Sur ce bureau, les connexions réseau sont gérées par Linux. Aucun modem téléphonique n’est connecté.',
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (location.hostname === 'settings.localhost') {
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
const link = event.target.closest(
|
||||
'a[href="#fxa"],a[href="#fxa-about"],a[href="#fxa-signin"],a[href="#fxa-sync"]',
|
||||
);
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
unavailable(
|
||||
'Les comptes Firefox et la synchronisation distante ne sont pas portés dans cette version bureau.',
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (location.hostname === 'communications.localhost') {
|
||||
document.getElementById('keypad-callbar-call-action')?.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
if (navigator.mozTelephony || window.VulpesPlatform) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
unavailable(
|
||||
'Appels indisponibles : aucun modem téléphonique n’est connecté à cette version bureau.',
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
Object.defineProperty(navigator, 'mozAlarms', {
|
||||
value: {
|
||||
getAll: () =>
|
||||
request(
|
||||
call('alarms.request', { operation: 'getAll' }).then((list) =>
|
||||
list.map((r) => ({ ...r, date: new Date(r.date) })),
|
||||
),
|
||||
),
|
||||
add: (date, respectTimezone, data) =>
|
||||
request(
|
||||
call('alarms.request', {
|
||||
operation: 'add',
|
||||
date: +date,
|
||||
respectTimezone,
|
||||
data: data ?? null,
|
||||
local: [
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds(),
|
||||
date.getMilliseconds(),
|
||||
],
|
||||
}),
|
||||
),
|
||||
remove: (id) => {
|
||||
call('alarms.request', { operation: 'remove', id }).catch(console.error);
|
||||
},
|
||||
},
|
||||
});
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
if (event.detail.type === 'alarm') {
|
||||
const data = event.detail.data;
|
||||
handlers.get('alarm')?.({ ...data, date: new Date(data.date) });
|
||||
}
|
||||
});
|
||||
window.VulpesCompat.openAttention = (url) => {
|
||||
const target = new URL(url, location.href);
|
||||
if (
|
||||
target.origin !== location.origin ||
|
||||
target.pathname !== '/onring.html' ||
|
||||
location.hostname !== 'clock.localhost'
|
||||
)
|
||||
throw new DOMException('Unsupported attention view', 'NotSupportedError');
|
||||
const frame = document.createElement('iframe');
|
||||
frame.dataset.vulpesAttention = 'true';
|
||||
frame.src = target.href;
|
||||
frame.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;z-index:10000';
|
||||
document.body.append(frame);
|
||||
call('apps.launch', { manifestURL: location.origin + '/manifest.webapp' }).catch(console.error);
|
||||
return frame.contentWindow;
|
||||
};
|
||||
if (
|
||||
location.hostname === 'clock.localhost' &&
|
||||
location.pathname === '/onring.html' &&
|
||||
window.frameElement?.dataset.vulpesAttention === 'true'
|
||||
) {
|
||||
window.opener = parent;
|
||||
window.close = () => {
|
||||
dispatchEvent(new Event('beforeunload'));
|
||||
window.frameElement.remove();
|
||||
};
|
||||
}
|
||||
const ports = new Map();
|
||||
function portFor({ id }) {
|
||||
if (ports.has(id)) return ports.get(id);
|
||||
const port = new EventTarget();
|
||||
let listener = null,
|
||||
started = false,
|
||||
queue = [];
|
||||
const deliver = (data) => port.dispatchEvent(new MessageEvent('message', { data }));
|
||||
port.start = () => {
|
||||
started = true;
|
||||
for (const data of queue) deliver(data);
|
||||
queue = [];
|
||||
};
|
||||
Object.defineProperty(port, 'onmessage', {
|
||||
get: () => listener,
|
||||
set(fn) {
|
||||
if (listener) port.removeEventListener('message', listener);
|
||||
listener = fn;
|
||||
if (fn) port.addEventListener('message', fn);
|
||||
port.start();
|
||||
},
|
||||
});
|
||||
port.postMessage = (data) =>
|
||||
call('iac.send', { id, data }).catch((error) =>
|
||||
port.dispatchEvent(new MessageEvent('messageerror', { data: error.message })),
|
||||
);
|
||||
port.close = () => {
|
||||
ports.delete(id);
|
||||
call('iac.close', { id }).catch(console.error);
|
||||
};
|
||||
port.receive = (data) => (started ? deliver(data) : queue.push(data));
|
||||
ports.set(id, port);
|
||||
return port;
|
||||
}
|
||||
window.VulpesCompat.connect = (keyword) =>
|
||||
call('iac.connect', { keyword }).then((list) => list.map(portFor));
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
const { type, data } = event.detail;
|
||||
if (type === 'connection')
|
||||
handlers.get('connection')?.({ keyword: data.keyword, port: portFor(data) });
|
||||
if (type === 'portmessage') portFor(data).receive(data.data);
|
||||
if (type === 'portclose') {
|
||||
const p = ports.get(data.id);
|
||||
ports.delete(data.id);
|
||||
p?.dispatchEvent(new Event('close'));
|
||||
}
|
||||
});
|
||||
function cursor(promise, convert = (value) => value) {
|
||||
let values,
|
||||
index = 0,
|
||||
ready = false;
|
||||
const c = new EventTarget();
|
||||
c.result = null;
|
||||
c.done = false;
|
||||
function next() {
|
||||
if (!ready) return;
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
c.result = index < values.length ? await convert(values[index++]) : null;
|
||||
c.done = c.result === null;
|
||||
c.onsuccess?.({ target: c });
|
||||
c.dispatchEvent(new Event('success'));
|
||||
} catch (error) {
|
||||
c.error = error;
|
||||
c.onerror?.({ target: c });
|
||||
c.dispatchEvent(new Event('error'));
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
c.continue = next;
|
||||
promise.then(
|
||||
(list) => {
|
||||
values = list;
|
||||
ready = true;
|
||||
next();
|
||||
},
|
||||
(error) => {
|
||||
c.error = error;
|
||||
c.onerror?.({ target: c });
|
||||
c.dispatchEvent(new Event('error'));
|
||||
},
|
||||
);
|
||||
return c;
|
||||
}
|
||||
window.VulpesCompat.cursor = cursor;
|
||||
if (location.hostname === 'sms.localhost') {
|
||||
Object.defineProperty(navigator, 'mozMobileConnections', { value: Object.freeze([]) });
|
||||
const messages = new EventTarget();
|
||||
for (const operation of ['getThreads', 'getMessages'])
|
||||
messages[operation] = (...args) =>
|
||||
cursor(call('messages.request', { operation, args }), (value) => ({
|
||||
...value,
|
||||
timestamp: new Date(value.timestamp),
|
||||
}));
|
||||
for (const operation of [
|
||||
'getMessage',
|
||||
'send',
|
||||
'sendMMS',
|
||||
'retrieveMMS',
|
||||
'delete',
|
||||
'markMessageRead',
|
||||
'getSmscAddress',
|
||||
'setSmscAddress',
|
||||
'getSegmentInfoForText',
|
||||
])
|
||||
messages[operation] = (...args) => request(call('messages.request', { operation, args }));
|
||||
const dated = value => value && typeof value==='object' && 'timestamp' in value ?
|
||||
{...value,timestamp:new Date(value.timestamp)} : value;
|
||||
messages.getMessage = id => request(call('messages.request',{operation:'getMessage',args:[id]}).then(dated));
|
||||
messages.send = (recipients,body,options) => {
|
||||
const send = recipient => {
|
||||
let id, settle;
|
||||
const early = new Map();
|
||||
const terminal = new Promise((resolve,reject)=>{settle=message=>{
|
||||
if(message.delivery==='sent') resolve(dated(message));
|
||||
else reject(new DOMException('SMS transmission failed','UnknownError'));
|
||||
};});
|
||||
const listener=({detail})=>{
|
||||
if(!['sms-sent','sms-failed'].includes(detail.type)) return;
|
||||
const message=detail.data;
|
||||
if(id===message.id) settle(message);
|
||||
else if(id===undefined && early.size<100) early.set(message.id,message);
|
||||
};
|
||||
addEventListener('vulpes-service-event',listener);
|
||||
const result=call('messages.request',{operation:'send',args:[recipient,body,options]}).then(message=>{
|
||||
if(message.delivery!=='sending') return dated(message);
|
||||
id=message.id;
|
||||
if(early.has(id)) settle(early.get(id));
|
||||
early.clear();
|
||||
return terminal;
|
||||
}).finally(()=>removeEventListener('vulpes-service-event',listener));
|
||||
return request(result);
|
||||
};
|
||||
return Array.isArray(recipients) ? recipients.map(send) : send(recipients);
|
||||
};
|
||||
addEventListener('vulpes-service-event',({detail})=>{
|
||||
const type = {'sms-received':'received','sms-sending':'sending','sms-sent':'sent','sms-failed':'failed'}[detail.type];
|
||||
if (!type) return;
|
||||
const message=dated(detail.data);
|
||||
const event=Object.assign(new Event(type),{message});
|
||||
messages.dispatchEvent(event);messages['on'+type]?.(event);
|
||||
});
|
||||
Object.defineProperty(navigator, 'mozMobileMessage', { value: messages });
|
||||
}
|
||||
if (
|
||||
['gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
|
||||
) {
|
||||
const stores = new Map();
|
||||
function storage(type) {
|
||||
if (!['pictures', 'music', 'videos'].includes(type)) return null;
|
||||
if (stores.has(type)) return stores.get(type);
|
||||
const s = new EventTarget();
|
||||
s.storageName = 'sdcard';
|
||||
s.default = true;
|
||||
s.canBeMounted = false;
|
||||
const run = (operation, params = {}) => call('media.request', { type, operation, ...params });
|
||||
for (const op of ['available', 'freeSpace', 'usedSpace']) s[op] = () => request(run(op));
|
||||
s.get = (name) =>
|
||||
request(
|
||||
run('get', { name }).then((f) => {
|
||||
const file = new File([f.blob], f.name, { type: f.type, lastModified: f.lastModified });
|
||||
Object.defineProperty(file, 'lastModifiedDate', { value: new Date(f.lastModified) });
|
||||
return file;
|
||||
}),
|
||||
);
|
||||
s.addNamed = (blob, name) => request(run('add', { blob, name }));
|
||||
s.add = (blob) =>
|
||||
s.addNamed(
|
||||
blob,
|
||||
blob.name ||
|
||||
crypto.randomUUID() + { pictures: '.jpg', music: '.mp3', videos: '.mp4' }[type],
|
||||
);
|
||||
s.delete = (name) => request(run('delete', { name }));
|
||||
s.enumerate = (name = '', options = {}) => {
|
||||
if (typeof name === 'object') {
|
||||
options = name;
|
||||
name = '';
|
||||
}
|
||||
return cursor(run('enumerate', { name, since: options.since ? +options.since : 0 }), (f) =>
|
||||
s.get(f.name),
|
||||
);
|
||||
};
|
||||
stores.set(type, s);
|
||||
return s;
|
||||
}
|
||||
navigator.getDeviceStorage = storage;
|
||||
navigator.getDeviceStorages = (type) => {
|
||||
const s = storage(type);
|
||||
return s ? [s] : [];
|
||||
};
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
const { type, data } = event.detail;
|
||||
if (type !== 'mediachange') return;
|
||||
const s = stores.get(data.type);
|
||||
if (!s) return;
|
||||
const change = new Event('change');
|
||||
Object.assign(change, { path: data.path, reason: data.reason });
|
||||
s.dispatchEvent(change);
|
||||
s.onchange?.(change);
|
||||
});
|
||||
addEventListener('DOMContentLoaded', () => {
|
||||
const type = { gallery: 'pictures', music: 'music', video: 'videos' }[
|
||||
location.hostname.split('.')[0]
|
||||
];
|
||||
if (!type || !['/', '/index.html'].includes(location.pathname) || window.VulpesPlatform)
|
||||
return;
|
||||
const label = document.createElement('label');
|
||||
label.className = 'vulpes-import';
|
||||
label.textContent = 'Importer';
|
||||
label.title = 'Copier des fichiers dans le profil de cet émulateur';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = { pictures: 'image/*', music: 'audio/*', videos: 'video/*' }[type];
|
||||
input.hidden = true;
|
||||
label.append(input);
|
||||
input.addEventListener('change', async () => {
|
||||
try {
|
||||
for (const file of input.files) await storage(type).addNamed(file, file.name);
|
||||
label.title = 'Import terminé';
|
||||
} catch (error) {
|
||||
unavailable('Import impossible : ' + error.message);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
document.body.append(label);
|
||||
});
|
||||
}
|
||||
class Contact {
|
||||
constructor(data = {}) {
|
||||
this.init(data);
|
||||
}
|
||||
init(data) {
|
||||
Object.assign(this, data);
|
||||
for (const field of [
|
||||
'name',
|
||||
'givenName',
|
||||
'familyName',
|
||||
'additionalName',
|
||||
'honorificPrefix',
|
||||
'honorificSuffix',
|
||||
'nickname',
|
||||
'email',
|
||||
'tel',
|
||||
'adr',
|
||||
'org',
|
||||
'jobTitle',
|
||||
'note',
|
||||
'url',
|
||||
'category',
|
||||
'photo',
|
||||
])
|
||||
if (!this[field]) this[field] = [];
|
||||
}
|
||||
}
|
||||
window.mozContact = Contact;
|
||||
const contacts = new EventTarget();
|
||||
function convert(value) {
|
||||
const c = new Contact(value);
|
||||
for (const key of ['published', 'updated', 'bday', 'anniversary'])
|
||||
if (c[key]) c[key] = new Date(c[key]);
|
||||
return c;
|
||||
}
|
||||
contacts.find = (options) =>
|
||||
request(
|
||||
call('contacts.request', { operation: 'find', options }).then((list) => list.map(convert)),
|
||||
);
|
||||
contacts.getAll = (options) =>
|
||||
cursor(call('contacts.request', { operation: 'all', options }), convert);
|
||||
contacts.save = (contact) =>
|
||||
request(
|
||||
call('contacts.request', { operation: 'save', contact }).then((saved) => {
|
||||
Object.assign(contact, convert(saved));
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
contacts.remove = (contact) =>
|
||||
request(
|
||||
call('contacts.request', {
|
||||
operation: 'remove',
|
||||
id: typeof contact === 'string' ? contact : contact.id,
|
||||
}),
|
||||
);
|
||||
contacts.clear = () => request(call('contacts.request', { operation: 'clear' }));
|
||||
contacts.getCount = () => request(call('contacts.request', { operation: 'count' }));
|
||||
contacts.getRevision = () => request(call('contacts.request', { operation: 'revision' }));
|
||||
Object.defineProperty(navigator, 'mozContacts', { value: contacts });
|
||||
addEventListener('vulpes-service-event', (event) => {
|
||||
if (event.detail.type !== 'contactchange') return;
|
||||
const change = new Event('contactchange');
|
||||
Object.assign(change, event.detail.data);
|
||||
contacts.dispatchEvent(change);
|
||||
contacts.oncontactchange?.(change);
|
||||
});
|
||||
})();
|
||||
121
host/gaia-styles.js
Normal file
121
host/gaia-styles.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Legacy <style scoped> using native CSS @scope. Resolve imports before scoping
|
||||
// so rules cannot leak into unrelated Gaia elements while a stylesheet loads.
|
||||
(() => {
|
||||
// Generic sans-serif resolved to Noto on desktop and Fira on Sargo. Use the
|
||||
// bundled Gaia face in text rules, without changing icon font declarations.
|
||||
function textFonts() {
|
||||
const visit = sheet => {
|
||||
let rules;
|
||||
try { rules = sheet.cssRules; } catch (_) { return; }
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) visit(rule.styleSheet);
|
||||
if (rule.cssRules) visit(rule);
|
||||
const style = rule.style;
|
||||
if (!style?.fontFamily || /gaia-icons|fontawesome/i.test(style.fontFamily)) continue;
|
||||
const families = style.fontFamily.split(',').map(s=>s.trim());
|
||||
if (families[0] === 'FiraSans' || families[0] === '"FiraSans"') continue;
|
||||
const updated = families.map(f => /^(sans-serif|["']?Fira Sans["']?)$/i.test(f) ? 'FiraSans' : f).join(', ');
|
||||
if (updated !== style.fontFamily) style.setProperty('font-family', updated, style.getPropertyPriority('font-family'));
|
||||
}
|
||||
};
|
||||
const walk = root => {
|
||||
observeFonts(root);
|
||||
for (const sheet of root.styleSheets || []) visit(sheet);
|
||||
for (const sheet of root.adoptedStyleSheets || []) visit(sheet);
|
||||
for (const element of root.querySelectorAll('*')) {
|
||||
if (element.shadowRoot) walk(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
walk(document);
|
||||
}
|
||||
let fontsScheduled = false;
|
||||
function scheduleFonts() {
|
||||
if (fontsScheduled) return;
|
||||
fontsScheduled = true;
|
||||
requestAnimationFrame(() => { fontsScheduled = false; textFonts(); });
|
||||
}
|
||||
addEventListener('load', event => { if (event.target === document || event.target?.localName === 'link') scheduleFonts(); }, true);
|
||||
const observedRoots = new WeakSet();
|
||||
function observeFonts(root) {
|
||||
if (observedRoots.has(root)) return;
|
||||
observedRoots.add(root);
|
||||
new MutationObserver(records => {
|
||||
if (records.some(r => r.target.localName === 'style' || r.addedNodes.length)) scheduleFonts();
|
||||
}).observe(root,{childList:true,subtree:true});
|
||||
root.addEventListener('load', scheduleFonts, true);
|
||||
}
|
||||
observeFonts(document);
|
||||
const pending = new WeakSet();
|
||||
let sequence = 0;
|
||||
async function expand(css, base, seen = new Set()) {
|
||||
css = css.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/g, (all, q, url) =>
|
||||
url.startsWith('data:') ? all : 'url(' + JSON.stringify(new URL(url, base).href) + ')',
|
||||
);
|
||||
// Work on the rewritten imports to preserve relative URLs from each file.
|
||||
for (const match of [
|
||||
...css.matchAll(/@import\s+(?:url\(\s*)?["']?([^"'\s);]+)["']?\s*\)?\s*;/g),
|
||||
]) {
|
||||
const url = new URL(match[1], base).href;
|
||||
if (seen.has(url)) {
|
||||
css = css.replace(match[0], '');
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Scoped stylesheet: ' + url);
|
||||
css = css.replace(match[0], await expand(await response.text(), url, seen));
|
||||
}
|
||||
return css;
|
||||
}
|
||||
// ComponentUtils used to copy a scoped sheet into its shadow tree. A scope
|
||||
// rooted on the light-DOM host cannot match inside a modern shadow root.
|
||||
window.VulpesCompat.componentStyle = async function (owner, base) {
|
||||
owner.style.visibility = 'hidden';
|
||||
try {
|
||||
const url = new URL(base + 'style.css', document.baseURI).href;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Component stylesheet: ' + url);
|
||||
const text = await expand(await response.text(), url);
|
||||
const light = document.createElement('style');
|
||||
light.setAttribute('scoped', '');
|
||||
light.textContent = text;
|
||||
owner.append(light);
|
||||
if (owner.shadowRoot) {
|
||||
const shadow = document.createElement('style');
|
||||
const tag = new RegExp('(?<![\\w-])' + owner.localName + '(?![\\w-])', 'g');
|
||||
shadow.textContent = text.replace(tag, ':host');
|
||||
owner.shadowRoot.append(shadow);
|
||||
}
|
||||
scheduleFonts();
|
||||
} finally {
|
||||
owner.style.visibility = '';
|
||||
}
|
||||
};
|
||||
function scan(root) {
|
||||
for (const style of root.querySelectorAll('style[scoped]')) {
|
||||
if (pending.has(style)) continue;
|
||||
pending.add(style);
|
||||
const owner = style.parentElement;
|
||||
if (!owner) continue;
|
||||
const css = style.textContent;
|
||||
style.type = 'text/vulpes-scoped';
|
||||
const id = owner.getAttribute('data-vulpes-scope') || 's' + ++sequence;
|
||||
owner.setAttribute('data-vulpes-scope', id);
|
||||
expand(css, document.baseURI)
|
||||
.then((text) => {
|
||||
// @scope excludes implicit matching of its root; explicitly target
|
||||
// the component itself as well as its light DOM descendants.
|
||||
const tag = new RegExp('(?<![\\w-])' + owner.localName + '(?![\\w-])', 'g');
|
||||
// Only a custom element's type selector names the scope root. A div
|
||||
// scope still contains ordinary div descendants (notably Lockscreen).
|
||||
if (owner.localName.includes('-')) text = text.replace(tag, ':scope');
|
||||
text = text.replace(/\.-(?:host|content)(?![\w-])/g, (match) => ':scope' + match);
|
||||
style.textContent = '@scope ([data-vulpes-scope="' + id + '"]) {' + text + '}';
|
||||
style.removeAttribute('scoped');
|
||||
style.type = 'text/css';
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
new MutationObserver(() => scan(document)).observe(document, { childList: true, subtree: true });
|
||||
})();
|
||||
96
host/gaia-wifi.js
Normal file
96
host/gaia-wifi.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Tundra's NetworkManager broker, exposed through Gaia's legacy Wi-Fi interface.
|
||||
(() => {
|
||||
if (!window.VulpesNativeWifi || !['system','settings'].includes(location.hostname.split('.')[0])) return;
|
||||
const {call, request} = VulpesCompat;
|
||||
const manager = Object.assign(new EventTarget(), {
|
||||
enabled:false, connection:{status:'disconnected',network:null},
|
||||
connectionInformation:null, macAddress:'',
|
||||
});
|
||||
let networks = [];
|
||||
function emit(type, values = {}) {
|
||||
const event = Object.assign(new Event(type), values);
|
||||
manager.dispatchEvent(event); manager['on' + type]?.(event);
|
||||
}
|
||||
function power(enabled, acknowledge = false) {
|
||||
if (manager.enabled === enabled && !acknowledge) return;
|
||||
manager.enabled = enabled;
|
||||
emit(enabled ? 'enabled' : 'disabled');
|
||||
if (!enabled) update(null);
|
||||
}
|
||||
function update(network) {
|
||||
const status = network ? 'connected' : 'disconnected';
|
||||
const changed = manager.connection.status !== status || manager.connection.network?.ssid !== network?.ssid;
|
||||
manager.connection = {status, network};
|
||||
manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
|
||||
if (changed) emit('statuschange', manager.connection);
|
||||
emit('connectioninfoupdate', manager.connectionInformation || {});
|
||||
}
|
||||
let scanPending;
|
||||
function scan() {
|
||||
if (scanPending) return scanPending;
|
||||
scanPending = scanNetworks().finally(() => {scanPending = null;});
|
||||
return scanPending;
|
||||
}
|
||||
async function scanNetworks() {
|
||||
const result = await call('platform.wifi',{operation:'scan'});
|
||||
power(result.enabled !== false);
|
||||
networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal,
|
||||
security:n.security === 'open' ? [] : [n.security === 'wpa-psk' ? 'WPA-PSK' : n.security === 'sae' ? 'SAE' : 'WPA-EAP'],
|
||||
known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId}));
|
||||
update(networks.find(n => n.connected) || null);
|
||||
return networks;
|
||||
}
|
||||
manager.getNetworks = () => request(scan());
|
||||
manager.getKnownNetworks = () => request(call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id}))));
|
||||
manager.associate = network => request((async () => {
|
||||
if (network.security.length && network.security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY');
|
||||
await call('platform.wifi',{operation:'connect',id:network.vulpesId,password:network.psk || ''});
|
||||
await scan(); return network;
|
||||
})());
|
||||
manager.forget = network => request(call('platform.wifi',{operation:'forget',id:network.savedId}).then(()=>scan()));
|
||||
const unsupported = () => request(Promise.reject(new DOMException('Not supported by this adapter','NotSupportedError')));
|
||||
manager.wps = unsupported;
|
||||
manager.setPowerSavingMode = unsupported;
|
||||
Object.defineProperty(navigator,'mozWifiManager',{value:manager});
|
||||
addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const state = await call('platform.wifi',{operation:'status'});
|
||||
power(state.enabled);
|
||||
if (location.hostname.startsWith('system.')) {
|
||||
let queue=Promise.resolve();
|
||||
navigator.mozSettings.addObserver('wifi.enabled', event => {
|
||||
const enabled=event.settingValue===true;
|
||||
queue=queue.then(async () => {
|
||||
if(manager.enabled===enabled) {power(enabled,true);return;}
|
||||
try {
|
||||
const result=await call('platform.wifi',{operation:'setEnabled',enabled});
|
||||
power(result.enabled,true);
|
||||
} catch(error) {
|
||||
await call('settings.set',{values:{'wifi.enabled':manager.enabled}});
|
||||
console.warn('Wi-Fi radio change failed',error);
|
||||
}
|
||||
});
|
||||
});
|
||||
await call('settings.set',{values:{'wifi.enabled':state.enabled}});
|
||||
} else {
|
||||
navigator.mozSettings.addObserver('wifi.enabled', () => {
|
||||
call('platform.wifi',{operation:'status'}).then(s=>power(s.enabled)).catch(console.warn);
|
||||
});
|
||||
}
|
||||
// Settings may receive the preference before the radio operation completes.
|
||||
// Refresh while visible so its panel also follows hardware power changes.
|
||||
if(location.hostname.startsWith('settings.')) {
|
||||
let pending=false;
|
||||
const refresh=setInterval(async()=>{
|
||||
if(document.hidden || pending) return;
|
||||
pending=true;
|
||||
try {power((await call('platform.wifi',{operation:'status'})).enabled);}
|
||||
catch(error) {console.warn('Wi-Fi status refresh failed',error);}
|
||||
finally {pending=false;}
|
||||
},2000);
|
||||
addEventListener('pagehide',()=>clearInterval(refresh),{once:true});
|
||||
}
|
||||
if(manager.enabled) await scan();
|
||||
} catch(error) { console.warn('Wi-Fi initialization failed',error); }
|
||||
}, {once:true});
|
||||
})();
|
||||
397
host/platform-ui.js
Normal file
397
host/platform-ui.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
// 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); }
|
||||
};
|
||||
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) run('dial', {number});
|
||||
}, 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);
|
||||
}
|
||||
52
host/shell.css
Normal file
52
host/shell.css
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* Navigation matches the Android preview; Gaia owns the application theme. */
|
||||
#home {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 480px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #122332;
|
||||
color: white;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
[home-active] #home {
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
}
|
||||
[home-active][home-at-bottom] #home {
|
||||
background: transparent;
|
||||
}
|
||||
#home::before {
|
||||
content: '';
|
||||
box-sizing: border-box;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
#home:active::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
#home:focus-visible {
|
||||
outline: 2px solid #8ad7ff;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#home { left:auto; right:0; top:0; bottom:0; width:44px; height:100%; }
|
||||
[home-active] #home {
|
||||
background:linear-gradient(to left, rgba(0,0,0,0.8), transparent);
|
||||
}
|
||||
[home-active][home-at-bottom] #home { background:transparent; }
|
||||
}
|
||||
179
host/shell.js
Normal file
179
host/shell.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
'use strict';
|
||||
const { Runtime } = ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
|
||||
const { Views } = ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
|
||||
const fullscreenHost = Services.env.get('VULPES_FULLSCREEN') === '1';
|
||||
if (fullscreenHost) {
|
||||
// Set native window decoration before the Wayland surface is mapped.
|
||||
document.documentElement.setAttribute('hidechrome', 'true');
|
||||
document.documentElement.setAttribute('home-active', '');
|
||||
}
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
if (fullscreenHost) {
|
||||
window.fullScreen = true;
|
||||
let last = '', stableSince = performance.now();
|
||||
const deadline = performance.now() + 5000;
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const size = `${innerWidth}x${innerHeight}@${devicePixelRatio}`;
|
||||
if (size !== last) { last = size; stableSince = performance.now(); }
|
||||
if (window.fullScreen && innerWidth > 0 && innerHeight > 0 && performance.now() - stableSince >= 500) break;
|
||||
}
|
||||
}
|
||||
window.vulpesStartup = {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,fullscreen:window.fullScreen};
|
||||
await Runtime.init();
|
||||
Services.ppmm.sharedData.set('vulpes:native-input',Services.env.get('VULPES_TUNDRA')==='1' || Services.env.get('VULPES_TEST_INPUT')==='1');
|
||||
Services.ppmm.sharedData.flush();
|
||||
if (Services.env.get('VULPES_TUNDRA') === '1') {
|
||||
let session, sequence = 0, stopped = false;
|
||||
addEventListener('unload', () => { stopped = true; });
|
||||
const pollKeys = async () => {
|
||||
try {
|
||||
const state = await IOUtils.readJSON('/run/tundra-buttons/events.json');
|
||||
if (state.session !== session) { session = state.session; sequence = state.sequence; }
|
||||
else if (Date.now()/1000-state.recordedAt < 5) {
|
||||
for (const event of state.events) if (event.id > sequence) {
|
||||
Runtime.broadcast('hardware-key', {type:event.type}, 'system');
|
||||
sequence = event.id;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* Service may still be starting. */ }
|
||||
if (!stopped) setTimeout(pollKeys, 80);
|
||||
};
|
||||
pollKeys();
|
||||
}
|
||||
ChromeUtils.registerWindowActor('Vulpes', {
|
||||
parent: { esModuleURI: 'resource://vulpes/host/actors/VulpesParent.sys.mjs' },
|
||||
child: {
|
||||
esModuleURI: 'resource://vulpes/host/actors/VulpesChild.sys.mjs',
|
||||
events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true} },
|
||||
},
|
||||
allFrames: true,
|
||||
safeForUntrustedWebProcess: true,
|
||||
matches: Runtime.apps.map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
|
||||
});
|
||||
ChromeUtils.registerWindowActor('VulpesTouch', {
|
||||
child: {
|
||||
esModuleURI: 'resource://vulpes/host/actors/TouchChild.sys.mjs',
|
||||
events: { DOMDocElementInserted: { capture: true } },
|
||||
},
|
||||
allFrames: true,
|
||||
safeForUntrustedWebProcess: true,
|
||||
});
|
||||
const browser = document.createXULElement('browser');
|
||||
browser.id = 'system';
|
||||
browser.fxrPermissionPrompt = (request) => Runtime.platform.mediaPermission(browser, request);
|
||||
browser.setAttribute('type', 'content');
|
||||
browser.setAttribute('remote', 'true');
|
||||
browser.setAttribute('remoteType', 'web');
|
||||
browser.setAttribute('maychangeremoteness', 'true');
|
||||
browser.style.cssText = 'display:block;width:480px;height:800px';
|
||||
document.getElementById('screen').append(browser);
|
||||
// A standalone compositor owns the whole display; desktop windows retain
|
||||
// their existing phone dimensions unless the launcher requests fullscreen.
|
||||
{
|
||||
const screen = document.getElementById('screen');
|
||||
const homeButton = document.getElementById('home');
|
||||
const resize = () => {
|
||||
const landscape = window.innerWidth > window.innerHeight;
|
||||
const overlay = document.documentElement.hasAttribute('home-active');
|
||||
const width = Math.max(1, window.innerWidth - (landscape && !overlay ? 44 : 0));
|
||||
const height = Math.max(1, window.innerHeight - (!landscape && !overlay ? 44 : 0));
|
||||
screen.style.width = width + 'px';
|
||||
screen.style.height = height + 'px';
|
||||
browser.style.width = width + 'px';
|
||||
browser.style.height = height + 'px';
|
||||
homeButton.style.width = landscape ? '44px' : window.innerWidth + 'px';
|
||||
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
window.setHomeSurface = (home) => {
|
||||
document.documentElement.toggleAttribute('home-active', home);
|
||||
resize();
|
||||
};
|
||||
window.setHomeScrollBottom = (bottom) => {
|
||||
document.documentElement.toggleAttribute('home-at-bottom', bottom);
|
||||
};
|
||||
if (Services.env.get('VULPES_FULLSCREEN') === '1') window.fullScreen = true;
|
||||
resize();
|
||||
}
|
||||
|
||||
// Unlike a Firefox tab, this browser has no tab manager to activate it.
|
||||
// Wayland otherwise leaves its documents hidden and CSS animations paused.
|
||||
const activate = () => {
|
||||
if (Services.env.get('VULPES_FULLSCREEN') === '1') window.browsingContext.isActive = true;
|
||||
browser.docShellIsActive = true;
|
||||
};
|
||||
browser.addEventListener('XULFrameLoaderCreated', activate);
|
||||
browser.addEventListener('DidChangeBrowserRemoteness', activate);
|
||||
browser.addProgressListener(
|
||||
{
|
||||
QueryInterface: ChromeUtils.generateQI([
|
||||
'nsIWebProgressListener',
|
||||
'nsISupportsWeakReference',
|
||||
]),
|
||||
onLocationChange: activate,
|
||||
onStateChange: activate,
|
||||
},
|
||||
Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_WINDOW,
|
||||
);
|
||||
activate();
|
||||
window.addEventListener('activate', activate);
|
||||
window.addEventListener('sizemodechange', activate);
|
||||
window.addEventListener('MozAfterPaint', activate, { once: true });
|
||||
// The fullscreen widget is mapped asynchronously under Wayland.
|
||||
if (Services.env.get('VULPES_FULLSCREEN') === '1') window.setTimeout(activate, 1000);
|
||||
const nativeTouch = Services.env.get('VULPES_NATIVE_TOUCH') === '1';
|
||||
Services.ppmm.sharedData.set('vulpes:native-touch', nativeTouch);
|
||||
if (!nativeTouch) {
|
||||
browser.browsingContext.inRDMPane = true;
|
||||
browser.browsingContext.setRDMPaneMaxTouchPoints(1);
|
||||
browser.browsingContext.touchEventsOverride = 'enabled';
|
||||
}
|
||||
Services.ppmm.sharedData.set('vulpes:host-contexts', [browser.browsingContext.id]);
|
||||
Services.ppmm.sharedData.flush();
|
||||
browser.loadURI(
|
||||
Services.io.newURI(Runtime.apps.find((app) => app.id === 'system').origin + '/index.html'),
|
||||
{
|
||||
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
|
||||
},
|
||||
);
|
||||
window.hostReady = true;
|
||||
// Standalone windows have no SessionStore to release extension startup.
|
||||
// Firefox exposes this notification for embedders (e.g. Firefox Reality).
|
||||
Services.obs.notifyObservers(null, 'extensions-late-startup');
|
||||
const pruneTimer = setInterval(() => Views.prune(), 1000);
|
||||
window.addEventListener('unload', () => clearInterval(pruneTimer), { once: true });
|
||||
const home = document.getElementById('home');
|
||||
let holdTimer,
|
||||
held = false;
|
||||
home.title = 'Accueil · maintenir pour les applications ouvertes (Ctrl+Espace)';
|
||||
home.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
held = false;
|
||||
home.setPointerCapture(event.pointerId);
|
||||
holdTimer = setTimeout(() => {
|
||||
held = true;
|
||||
Runtime.broadcast('holdhome', {}, 'system');
|
||||
}, 650);
|
||||
});
|
||||
const cancel = () => clearTimeout(holdTimer);
|
||||
home.addEventListener('contextmenu', event => event.preventDefault());
|
||||
home.addEventListener('pointerup', cancel);
|
||||
home.addEventListener('pointercancel', cancel);
|
||||
home.addEventListener('click', () => {
|
||||
if (!held) Runtime.broadcast('home', {}, 'system');
|
||||
held = false;
|
||||
});
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.ctrlKey && event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
Runtime.broadcast('holdhome', {}, 'system');
|
||||
}
|
||||
});
|
||||
dump('VULPES CURRENT HOST ' + Services.appinfo.platformVersion + '\n');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dump('VULPES HOST ERROR ' + error + '\n' + error.stack + '\n');
|
||||
}
|
||||
});
|
||||
9
host/shell.xhtml
Normal file
9
host/shell.xhtml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0"?>
|
||||
<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://vulpes/content/shell.css" type="text/css"?>
|
||||
<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml" windowtype="vulpes:host" title="Vulpes — port Gecko actuel" width="480" height="844">
|
||||
<script src="chrome://global/content/customElements.js"/>
|
||||
<script src="chrome://vulpes/content/shell.js"/>
|
||||
<html:div id="screen" style="width:480px;height:800px;background:white;position:relative"/>
|
||||
<html:button id="home" aria-label="Accueil Vulpes" title="Accueil · maintenir pour les applications ouvertes"/>
|
||||
</window>
|
||||
Loading…
Reference in a new issue