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.
228 lines
9.4 KiB
JavaScript
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');
|
|
},
|
|
};
|