VulpesOS/host/Views.sys.mjs
OverSu 0041cd9660 Update shared Gaia workflows and Sargo development tooling
Fix camera switching and Contacts/SMS activity routing across shared hosts.
Integrate Marketplace installation and native standalone recovery tools,
Bluetooth runtime packages and trusted certificates.

Update English/French READMEs and the shared changelog with tested scope
and remaining radio/audio/Bluetooth limitations. Preview17 remains the
existing APK; no new native image is released.

Validation: shared JavaScript and native Python tests; Android packaging
and payload parity from an isolated publication checkout.
2026-10-08 14:27:17 +02:00

228 lines
9.4 KiB
JavaScript

// 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();
},
hideAll() {
for (const entry of this.entries.values()) entry.browser.style.visibility='hidden';
},
async layout(entry) {
const geometry=entry.geometry;
if (!geometry) return;
let {left,top,width,height,visible}=geometry;
let right=left+width,bottom=top+height;
try {
// A child document can remain visible while its owning Gaia app is hidden.
// Check every embedding frame in its own process, up to the system app.
for (let child=entry.actor.browsingContext;child.parent;child=child.parent) {
const state=await child.parent.currentWindowGlobal.getActor('Vulpes').sendQuery(
'Vulpes:ViewState',{contextId:child.id});
visible=visible && state.visible;
if (!visible) break;
left=Math.max(left,state.left);top=Math.max(top,state.top);
right=Math.min(right,state.right);bottom=Math.min(bottom,state.bottom);
}
} catch (_) {visible=false;}
if (entry.geometry!==geometry || !entry.browser.isConnected) return;
const shell=entry.browser.ownerDocument.defaultView;
const bounds=shell.document.getElementById('screen').getBoundingClientRect();
const x=shell.mozInnerScreenX+bounds.left,y=shell.mozInnerScreenY+bounds.top;
left=Math.max(x,left);top=Math.max(y,top);
right=Math.min(x+bounds.width,right);bottom=Math.min(y+bounds.height,bottom);
Object.assign(entry.browser.style,{left:(left-x)+'px',top:(top-y)+'px',
width:Math.max(0,right-left)+'px',height:Math.max(0,bottom-top)+'px',
visibility:visible && right>left && bottom>top?'visible':'hidden'});
},
prune() {
for (const [id, entry] of this.entries) {
if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry);
continue;
}
this.layout(entry);
const document=entry.browser.browsingContext?.currentWindowGlobal;
const page=document?.documentURI.spec || '';
// A retry keeps the previous error document until the next navigation commits.
if (document?.innerWindowId!==entry.previousDocumentId &&
/^about:(neterror|certerror|tabcrashed)/.test(page) && entry.errorPage!==page) {
entry.errorPage=page;
try {entry.actor.sendAsyncMessage('Vulpes:Event',{type:'view',
data:{contextId:id,type:'error',detail:{type:'other'}}});} catch (_) {}
}
// 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);
// Replacing a navigation cancels the previous request normally.
if (!Components.isSuccessCode(status) && status !== Cr.NS_BINDING_ABORTED)
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.errorPage=null;
entry.previousDocumentId=entry.browser.browsingContext?.currentWindowGlobal?.innerWindowId;
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');
entry.geometry={left,top,width,height,visible};
await this.layout(entry);
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');
},
};