Use asyncrhonous messages to deliver SyncMessage payloads on Firefox.

This commit is contained in:
hackademix 2019-10-22 09:46:31 +02:00
parent 534ab54c28
commit d196982cd5
1 changed files with 115 additions and 86 deletions

View File

@ -7,14 +7,33 @@
if (typeof browser.runtime.onSyncMessage !== "object") { if (typeof browser.runtime.onSyncMessage !== "object") {
// Background Script side // Background Script side
// cache of senders from early async messages to track tab ids in Firefox
let pending = new Map(); let pending = new Map();
if (MOZILLA) { if (MOZILLA) {
// we don't care this is async, as long as it get called before the // we don't care this is async, as long as it get called before the
// sync XHR (we are not interested in the response on the content side) // sync XHR (we are not interested in the response on the content side)
browser.runtime.onMessage.addListener((m, sender) => { browser.runtime.onMessage.addListener((m, sender) => {
if (!m.___syncMessageId) return; let wrapper = m.__syncMessage__;
pending.set(m.___syncMessageId, sender); if (!wrapper) return;
let {id} = wrapper;
pending.set(id, wrapper);
let result;
let unsuspend = result => {
pending.delete(id);
if (wrapper.unsuspend) {
setTimeout(wrapper.unsuspend, 0);
}
return result;
}
try {
result = notifyListeners(JSON.stringify(wrapper.payload), sender);
} catch(e) {
unsuspend();
throw e;
}
console.debug("sendSyncMessage: returning", result);
return (result instanceof Promise ? result
: new Promise(resolve => resolve(result))
).then(result => unsuspend(result));
}); });
} }
@ -26,6 +45,7 @@
let obrListener = request => { let obrListener = request => {
try {
let {url, tabId} = request; let {url, tabId} = request;
let params = new URLSearchParams(url.split("?")[1]); let params = new URLSearchParams(url.split("?")[1]);
let msgId = params.get("id"); let msgId = params.get("id");
@ -33,91 +53,73 @@
return asyncRet(msgId); return asyncRet(msgId);
} }
let msg = params.get("msg"); let msg = params.get("msg");
if (MOZILLA || tabId === TAB_ID_NONE) {
// this shoud be a mozilla suspension request
return params.get("suspend") ? new Promise(resolve => {
if (pending.has(msgId)) {
let wrapper = pending.get(msgId);
if (!wrapper.unsuspend) {
wrapper.unsuspend = resolve;
return;
}
}
resolve();
}).then(() => ret("go on"))
: CANCEL; // otherwise, bail
}
// CHROME from now on
let documentUrl = params.get("url"); let documentUrl = params.get("url");
let suspension = !!params.get("suspend"); let {frameAncestors, frameId} = request;
let sender; let isTop = frameId === 0 || !!params.get("top");
if (tabId === TAB_ID_NONE || suspension) { let tabUrl = frameAncestors && frameAncestors.length
// Firefox sends privileged content script XHR without valid tab ids && frameAncestors[frameAncestors.length - 1].url;
// so we cache sender info from unprivileged XHR correlated by msgId
if (pending.has(msgId)) { if (!tabUrl) {
sender = pending.get(msgId); if (isTop) {
if (suspension) { // we hold any script execution / DOM modification on this promise tabUrlCache.set(tabId, tabUrl = documentUrl);
return new Promise(resolve => { if (!tabRemovalListener) {
sender.unsuspend = resolve; browser.tabs.onRemoved.addListener(tabRemovalListener = tab => {
tabUrlCache.delete(tab.id);
}); });
} }
if (sender.unsuspend) {
let {unsuspend} = sender;
delete sender.unsuspend;
setTimeout(unsuspend(ret("unsuspend")), 0);
}
pending.delete(msgId);
} else { } else {
throw new Error(`sendSyncMessage: cannot correlate sender info for ${msgId}.`); tabUrl = tabUrlCache.get(tabId);
} }
} else {
let {frameAncestors, frameId} = request;
let isTop = frameId === 0 || !!params.get("top");
let tabUrl = frameAncestors && frameAncestors.length
&& frameAncestors[frameAncestors.length - 1].url;
if (!tabUrl) {
if (isTop) {
tabUrlCache.set(tabId, tabUrl = documentUrl);
if (!tabRemovalListener) {
browser.tabs.onRemoved.addListener(tabRemovalListener = tab => {
tabUrlCache.delete(tab.id);
});
}
} else {
tabUrl = tabUrlCache.get(tabId);
}
}
sender = {
tab: {
id: tabId,
url: tabUrl
},
frameId,
url: documentUrl,
timeStamp: Date.now()
};
} }
let sender = {
tab: {
id: tabId,
url: tabUrl
},
frameId,
url: documentUrl,
timeStamp: Date.now()
};
if (!(msg !== null && sender)) { if (!(msg !== null && sender)) {
return CANCEL; return CANCEL;
} }
// Just like in the async runtime.sendMessage() API, let result = notifyListeners(msg, sender);
// we process the listeners in order until we find a not undefined
// result, then we return it (or undefined if none returns anything).
let result;
for (let l of listeners) {
try {
if ((result = l(JSON.parse(msg), sender)) !== undefined) break;
} catch (e) {
console.error("%o processing message %o from %o", e, msg, sender);
}
}
if (result instanceof Promise) { if (result instanceof Promise) {
if (MOZILLA) {
// Firefox supports asynchronous webRequest listeners, so we can // On Chromium, if the promise is not resolved yet,
// just defer the return // we redirect the XHR to the same URL (hence same msgId)
return (async () => ret(await result))(); // while the result get cached for asynchronous retrieval
} else { result.then(r => {
// On Chromium, if the promise is not resolved yet, asyncResults.set(msgId, result = r);
// we redirect the XHR to the same URL (hence same msgId) });
// while the result get cached for asynchronous retrieval return asyncResults.has(msgId)
result.then(r => { ? asyncRet(msgId) // promise was already resolved
asyncResults.set(msgId, result = r); : {redirectUrl: url.replace(
}); /&redirects=(\d+)|$/, // redirects count to avoid loop detection
return asyncResults.has(msgId) (all, count) => `&redirects=${parseInt(count) + 1 || 1}`)};
? asyncRet(msgId) // promise was already resolved
: {redirectUrl: url.replace(
/&redirects=(\d+)|$/, // redirects count to avoid loop detection
(all, count) => `&redirects=${parseInt(count) + 1 || 1}`)};
}
} }
return ret(result); return ret(result);
}; } catch(e) {
console.error(e);
return CANCEL;
} };
let ret = r => ({redirectUrl: `data:application/json,${JSON.stringify(r)}`}) let ret = r => ({redirectUrl: `data:application/json,${JSON.stringify(r)}`})
let asyncRet = msgId => { let asyncRet = msgId => {
@ -127,6 +129,19 @@
} }
let listeners = new Set(); let listeners = new Set();
function notifyListeners(msg, sender) {
// Just like in the async runtime.sendMessage() API,
// we process the listeners in order until we find a not undefined
// result, then we return it (or undefined if none returns anything).
for (let l of listeners) {
try {
let result = l(JSON.parse(msg), sender);
if (result !== undefined) return result;
} catch (e) {
console.error("%o processing message %o from %o", e, msg, sender);
}
}
}
browser.runtime.onSyncMessage = { browser.runtime.onSyncMessage = {
ENDPOINT_PREFIX, ENDPOINT_PREFIX,
addListener(l) { addListener(l) {
@ -163,30 +178,46 @@
// about frameAncestors // about frameAncestors
url += "&top=true"; url += "&top=true";
} }
let finalizers = [];
if (MOZILLA) { if (MOZILLA) {
// on Firefox we first need to send an async message telling the // on Firefox we first need to send an async message telling the
// background script about the tab ID, which does not get sent // background script about the tab ID, which does not get sent
// with "privileged" XHR // with "privileged" XHR
browser.runtime.sendMessage({___syncMessageId: msgId}); let result;
browser.runtime.sendMessage(
{__syncMessage__: {id: msgId, payload: msg}}
).then(r => {
result = r;
}).catch(e => {
throw e;
});
// In order to cope with inconsistencies in XHR synchronicity, // In order to cope with inconsistencies in XHR synchronicity,
// allowing DOM element to be inserted and script to be executed // allowing DOM element to be inserted and script to be executed
// (seen with file:// and ftp:// loads) we additionally suspend on // (seen with file:// and ftp:// loads) we additionally suspend on
// Mutation notifications and beforescriptexecute events // Mutation notifications and beforescriptexecute events
let suspendURL = url + "&suspend"; let suspendURL = url + "&suspend=true";
let suspended = false;
let suspend = () => { let suspend = () => {
let r = new XMLHttpRequest(); if (result || suspended) return;
r.open("GET", url, false); suspended = true;
r.send(null); try {
let r = new XMLHttpRequest();
r.open("GET", suspendURL, false);
r.send(null);
} finally {
suspended = false;
}
}; };
let domSuspender = new MutationObserver(suspend); let domSuspender = new MutationObserver(suspend);
domSuspender.observe(document.documentElement, {childList: true}); domSuspender.observe(document.documentElement, {childList: true});
addEventListener("beforescriptexecute", suspend, true); addEventListener("beforescriptexecute", suspend, true);
finalizers.push(() => { try {
suspend();
} finally {
removeEventListener("beforescriptexecute", suspend, true); removeEventListener("beforescriptexecute", suspend, true);
domSuspender.disconnect(); domSuspender.disconnect();
}); }
return result;
} }
// then we send the payload using a privileged XHR, which is not subject // then we send the payload using a privileged XHR, which is not subject
// to CORS but unfortunately doesn't carry any tab id except on Chromium // to CORS but unfortunately doesn't carry any tab id except on Chromium
@ -199,8 +230,6 @@
return JSON.parse(r.responseText); return JSON.parse(r.responseText);
} catch(e) { } catch(e) {
console.error(`syncMessage error in ${document.URL}: ${e.message} (response ${r.responseText})`); console.error(`syncMessage error in ${document.URL}: ${e.message} (response ${r.responseText})`);
} finally {
for (let f of finalizers) try { f(); } catch(e) { console.error(e); }
} }
return null; return null;
}; };