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
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),
|
||||
);
|
||||
})();
|
||||
Loading…
Reference in a new issue