I'm writing a Firefox browser extension, and I'm stuck on how to wait for a content script to load before sending a message from the background script.
This is the sequence I'm trying to achieve:
User clicks context menu item (click handler is in background script)
Background script creates new tab
Content script loads fully in new tab
Background script sends message (with data) to content script
Content script uses data
Obviously, the content script needs to be loaded for step 4 to work; otherwise, the message doesn't get received.
I looked at previous similar questions, but most of the answers are incorrect (they wrap the event listener methods in a Promise, which either results in too many listeners or too few Promises), or they seem not-applicable to my scenario (those answers get around the question entirely by putting one callback inside the other; that wouldn't work here).
What I did try so far was to have the content script send a message when it's ready, and that works, but I'm still not sure how to have the click handler (from step 1) wait for a message from the content script (hypothetical step 3.5).
I assume I'd have to define the message handler outside the click handler, as far as I know, unless there's a way to receive the message inside the click handler.
Here's my current code as a minimal working example:
background.js:
let ports = {
'1': null,
'2': null
};
xyz = () => { /*...*/ }
tabHasLoaded = () => { /*...*/ }
browser.runtime.onConnect.addListener(connectHandler);
connectHandler = (p) => {
ports[p.name] = p;
switch (p.name) {
case '1':
ports['1'].addListener(xyz);
break;
case '2':
ports['2'].addListener(tabHasLoaded);
break;
}
};
browser.contextMenus.onClicked.addListener((info, tab) => {
let data, uri;
//...
browser.tabs.create({
url: uri
}).then((tab) => {
// need to wait for tabHasLoaded() to get called
ports['2'].postMessage({
msg: data
})
});
});
1.js (content script for something else):
let myPort = browser.runtime.connect({
name: '1'
});
document.addEventListener("click", (e) => {
myPort.postMessage({
msg: e.target.id
});
});
2.js (content script for new tab, after clicking context menu):
let myPort = browser.runtime.connect({
name: '2'
});
myPort.postMessage({
msg: "READY" // tabHasLoaded() should now get called in background.js
});
myPort.onMessage.addListener((msg) => {
// waiting for background.js to send me data
});
Is there an ideal way to handle this?
i still think promises are the way to go...
update
change code to use your MWE... please note that this is untested/not-optimized code just to outline the idea... it should look something like this:
background.js
let ports = {
'1': null,
'2': null
};
xyz = () => { /*...*/ }
browser.runtime.onConnect.addListener(connectHandler);
connectHandler = (p) => {
ports[p.name] = p;
switch (p.name) {
case '1':
ports['1'].addListener(xyz);
break;
}
};
browser.contextMenus.onClicked.addListener(async (info, tab) => {
let data, uri;
//...
const tab = await LoadAndWaitForPort2(uri)
ports['2'].postMessage({msg: data})
});
function LoadAndWaitForPort2(uri){
return new Promise((resolve, reject)=>{
const tab
const tabHasLoaded = (evt) => {
if(evt.data.msg === "READY"){
ports['2'].removeListener(tabHasLoaded)
resolve(tab)
} else {
reject("error!")
}
}
ports['2'].addListener(tabHasLoaded)
tab = await browser.tabs.create({url: uri})
})
}
2.js
let myPort = browser.runtime.connect({
name: '2'
});
myPort.postMessage({
msg: "READY" // tabHasLoaded() should now get called in background.js
});
myPort.onMessage.addListener((msg) => {
// waiting for background.js to send me data
});
Related
I am working on a project that creates a google chrome extension and I am using chrome API's in it. Now, I am trying to work my handleTabUpdate function when tab is updated. However, I am getting Unchecked runtime.lastError: No tab with id: 60
How can I fixed that? Here is my code:
chrome.tabs.onUpdated.addListener(handleTabUpdate)
function handleTabUpdate(tabId, info) {
if (info.status === 'loading') {
store.dispatch({ type: 'RESET_TABHOSTS' })
chrome.tabs.get(tabId, (activeTab) => {
if (tabId === store.getState().currentTab['id']) {
store.dispatch({ type: 'ACTIVE_TAB', payload: activeTab })
}
})
}
}
My guess is the tab you are looking for was closed, so when you try to get it by id the operation fails.
To avoid the error, my suggestion is to first query all tabs and see if a tab with a specific id exists in the result. If it does, run chrome.tabs.get() and with your logic.
Just bumped up against this issue in MV3 and I've tooled a solution that allows a bit more ease when working with tabs.
Functions
const handleRuntimeError = () => {
const error = chrome.runtime.lastError;
if (error) {
throw new Error(error);
}
};
const safeGetTab = async (tabId) => {
const tab = await chrome.tabs.get(parseInt(tabId));
try {
handleRuntimeError();
return tab;
} catch (e){
console.log('safeGetTab', e.message);
}
return {};
};
Implementation
(async () => {
// assumes some tabId
const tab = await safeGetTab(tabId);
})()
This will return a value no matter what. It will either return the tab object or an empty object. Then you can can just do some basic checking in your script to decide how you want to handle that. In my case I can simply ignore the action that would have been taken on that tab and move on.
I use the following to send some data to another window;
try{
win.webContents.once('dom-ready', () => win.webContents.send('send-data', data));
}
catch(err){
console.log("Caught ",err);
}
And for receivig;
ipcRenderer.on('send-data', function (event,data) {
console.log("Loaded ", data);
});
The thing is, the "data" here is sometimes assembled very quickly and it works fine. However, sometimes it takes a while and the other window is already loaded at this point. No data is received in that case, and no error message either. But then I can simply use the following to send it without problems;
win.webContents.send('send-data', data)
I couldn't find a way to apply for both cases. Any suggestions?
The short answer is no.
Electron doesn't have a function to wait for the window to load, then send a message, or send a message right away if the window's already loaded.
However this can be done with some simple code:
var hasWindowLoaded = false;
var hasDataBeenSent = false;
var data = {};
win.webContents.once('dom-ready', () => {
hasWindowLoaded = true;
if (!hasDataBeenSent && data) {
win.webContents.send('send-data', data);
hasDataBeenSent = true;
}
});
// Now add this where you build the `data` variable.
function loadData () {
data = {'sampleData': 'xyz'};
if (!hasDataBeenSent && hasWindowLoaded) {
win.webContents.send('send-data', data);
hasDataBeenSent = true;
}
}
Once the data's loaded in loadData it'll check if the window's finished loading and if it has then it sends the data right away.
Otherwise it stores the data in a varible (data) and once the window loads it sends it to the window.
Another approach that you may want to consider is sending data to the browserWindow using query strings.
const data = { hello: "world" }; // sample data
// send it using query strings
browserWindow.loadFile(YOUR_HTML_FILE_PATH, {
query: { data: JSON.stringify(data) },
});
// parse this data in the browserWindow
const querystring = require("querystring");
const query = querystring.parse(global.location.search);
const data = JSON.parse(query["?data"]);
console.log(data); // { hello: "world" }
When trigger copy event (cmd + c), our application needs to call and get data from web worker, and then set it to clipboard.
But after the data is ready, setting data to clipboard doesn’t seem to work.
async function process_copy(event) {
let data = await get_data_from_worker();
console.log("DATA FROM WORKER: ", data);
event.clipboardData.setData('text/plain', data);
event.clipboardData.setData('text/html', data);
event.preventDefault();
}
window.addEventListener('copy', process_copy.bind(this));
What I need is to set data to clipboard since the data from web worker is available for use.
The reason why I can’t use this command
document.execCommand('copy’)
Because time to get data from web worker may take more than 5 secs, and the command above doesn’t work in these cases.
Here is an example:
worker.js
onmessage = function(e) {
postMessage('WORKER DATA');
}
index.html
<!DOCTYPE html>
<html>
<body>
<script>
window.onload = function() {
const my_worker = new Worker("worker.js");
let call_back;
my_worker.onmessage = function(e) {
if(call_back){
call_back(e.data);
}
call_back = undefined;
}
function get_data_from_worker() {
return new Promise(
function(resolve, reject) {
call_back = resolve;
my_worker.postMessage("GET DATA");
}
)
}
async function process_copy(event) {
let data = await get_data_from_worker();
console.log("DATA FROM WORKER: ", data);
event.clipboardData.setData('text/plain', data);
event.clipboardData.setData('text/html', data);
event.preventDefault();
}
window.addEventListener('copy', process_copy.bind(this));
};
</script>
</body>
</html>
After users trigger the copy event, It calls to process_copy function, and waits for data.
In get_data_from_worker function, I have created a promise, which sends message to web worker, and then store resolve in call_back for later use.
When the web worker receive the message, it prepares data and send back, through postMessage method.
Then, the web worker message will be returned by call_back (inside my_worker.onmessage).
After that, the data is ready in process_copy function. But We can't set that data to clipboard.
You correctly identified the problem: you need to handle the event synchronously to be able to overwrite its default behavior.
You can workaround that issue by redesigning the workflow.
If your data really needs 5s to be generated, then you will most probably need two clicks from your users:
prepare the data
copy the data to clipboard
You don't need to actually handle their copy event to be able to set the data in there, so clicks will do, however, clicks are needed because the browsers won't let us copy anything in the clipboard without an user gesture, and after 5s most browsers will consider the user-gesture dead already.
btn.onclick = async (evt) => {
// from first click we prepare the data
btn.disabled = true;
btn.textContent = "Please wait";
// simulate waiting for worker
await wait(1000);
const datatext = "data as text";
const datahtml = "<h1>data as html</h1>";
// now that the data is ready
// we wait for the second click
btn.disabled = false;
btn.textContent = "Copy to clipboard";
btn.onclick = async (evt) => {
// we prepare to handle the click event
// so we can overwrite its content
addEventListener("copy", evt => {
evt.preventDefault();
evt.clipboardData.setData("text/plain", datatext);
evt.clipboardData.setData("text/html", datahtml);
}, { once: true });
// we force the copy event (we don't care of the content here)
document.execCommand("copy");
btn.remove();
pastezone.classList.remove("hidden");
};
};
pastezone.addEventListener("paste", (evt) => {
console.log("as text:", evt.clipboardData.getData("text/plain"));
console.log("as html:", evt.clipboardData.getData("text/html"));
});
function wait(ms) {
return new Promise( (res) => setTimeout(res, ms) );
}
.hidden { display: none; }
<button id="btn">Prepare data to copy</button>
<textarea id="pastezone">You can paste here to test
</textarea>
And if you only need to write text and need tosupport IE, you could also use the Async Clipboard API instead of document.execCommand, but this won't work here in StackOverflow's sandboxed snippets.
I'm building a Chrome Extension to add some shortcut functionality to a site I regularly work with. I've tried calling my addTypingListeners() to bind the div with 2 inputs that I've added to the title and subtitle of the edit page I'm working on. However, I never seem to get into the document.eventListener closure.
My Chrome Extension is run at document_idle so the content should be loaded by the time my additional code runs. How can I get these listeners to embed on the page?
Even when I don't call addTypingListeners(), I still see a and b log in the console
function addTypingListeners() {
console.log('a')
var meta = {}
document.addEventListener("DOMContentLoaded",()=>{
console.log('listeners added pre')
bind(meta, document.getElementsByTagName('title'), "title");
bind(meta, document.getElementsByTagName('subtitle'), "subtitle");
setInterval(()=>{document.getElementsByTagName('h3')[0].innerText=meta.title});
setInterval(()=>{
console.log(meta)
document.getElementsByTagName('h4')[0].innerText = meta.subtitle
});
console.log('listeners added')
})
console.log('b')
}
const start = async function() {
// var location = window.location.toString()
let slug = window.location.toString().split("/")[4]
let url = `https://example.org/${slug}?as=json`
const _ = await fetch(url)
.then(res => res.text())
.then(text => {
let obj = JSON.parse(text);
const { payload } = obj;
// Container
const root = document.getElementById('container');
var clippyContainer = document.createElement('div');
createShell(clippyContainer, name);
root.appendChild(clippyContainer);
// Inputs
const title = document.getElementsByTagName('h3')[0];
const subtitle = document.getElementsByTagName('h4')[0];
var inputDiv = document.createElement('div');
inputDiv.id = "input-div";
const titleInput = document.createElement('input');
titleInput.id = "title"
titleInput.value = title.innerText;
inputDiv.appendChild(titleInput);
const breaker = document.createElement("br")
inputDiv.appendChild(breaker);
const subtitleInput = document.createElement('input');
subtitleInput.id = "subtitle"
subtitleInput.value = subtitle.innerText;
inputDiv.appendChild(subtitleInput);
clippyContainer.appendChild(inputDiv);
inputDiv.appendChild(breaker);
// addTypingListeners() // tried here, also doesn't work
});
}
start()
.then( (_) => {
console.log('hi')
addTypingListeners()
console.log("done")
})
Probably the event DOMContentLoaded was already fired at the point of time when you set the listener. You can check that document.readyState equals to complete and execute the function without subscribing to the event if it already occurred. In the opposite case if the readyState is loading or interactive you should set the listener as it is currently done in the attached example.
The code you provided should be injected to the page as a content script (for details).
According to the official documentation, the order of events while a page is loading:
document_start > DOMContentLoaded > document_end > load > document_idle.
The difference between load and DOMContentLoaded events is explained here as
The load event is fired when the whole page has loaded, including all dependent resources such as stylesheets and images. This is in contrast to DOMContentLoaded, which is fired as soon as the page DOM has been loaded, without waiting for resources to finish loading.
Thus, you should add the listeners without waiting for the DOMContentLoaded event, which will never fire.
This is literally all the coded need besides whatever your doing to the dom.
Background.js
let slug = window.location.toString().split("/")[4]
let url = `https://example.org/${slug}?as=json`
fetch(url).then(res => res.text()).then((data) => {
chrome.tabs.sendMessage(tabId, {
message: data
});
})
Content.js
function addTypingListeners(data) {
// Update page dom
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.message) {
addTypingListeners(request.message);
}
});
I can return a value if I send a sync message:
// frame script
var chromeBtnText = sendSyncMessage("getChromeToolbarButtonText");
if (chromeBtnText == 'blah') {
alert('tool is blah');
}
// chrome script
messageManager.addMessageListener("getChromeToolbarButtonText", listener);
function listener(message) {
return document.getElementById('myChromeToolbarButton').label.value;
}
How do I achieve this with a callback with sendAsyncMessage?
I was hoping to do something like:
// frame script
function myCallback(val) {
var chromeBtnText = val;
if (chromeBtnText == 'blah') {
alert('tool is blah');
}
}
var chromeBtnText = sendAsyncMessage("getChromeToolbarButtonText", null, myCallback);
There is no callback for replies. In fact, there is no reply at all. The return value from the chrome message listener is simply ignored for async messages.
To do fully async communication, you'd have to send another message containing the reply.
Frame script
addMessageListener("getChromeToolbarButtonTextReply", function(message) {
alert(message.data.btnText);
});
sendAsyncMessage("getChromeToolbarButtonText");
Chrome
messageManager.addMessageListener("getChromeToolbarButtonText", function(message) {
var btnText = document.getElementById('myChromeToolbarButton').label.value;
// Only send message to the frame script/message manager
// that actually asked for it.
message.target.messageManager.sendAsyncMessage(
"getChromeToolbarButtonTextReply",
{btnText: btnText}
);
});
PS: All messages share a namespace. So to avoid conflicts when another piece of code wants to use the same name getChromeToolbarButtonText, you better choose a more unique name in the first place, like prefixing your messages with your add-on name my-unique-addoon:getChromeToolbarButtonText or something like that. ;)
I was also hoping to do something similar:
messageManager.sendAsyncMessage("my-addon-framescript-message", null, myCallback);
I'm going the other direction so the myCallback would be in chrome but it's exactly the same principle.
I'd used similar approaches to #Noitidart and #nmaier before but in this new case I wanted to bind to some local data so myCallback can behave differently based on the application state at the time the first message was sent rather than at the time the callback is executed, all while allowing for the possibility of multiple message round-trips being in progress concurrently.
Chrome:
let someLocalState = { "hello": "world" };
let callbackName = "my-addon-somethingUnique"; // based on current state or maybe generate a UUID
let myCallback = function(message) {
messageManager.removeMessageListener(callbackName, myCallback);
//message.data.foo == "bar"
//someLocalState.hello == "world"
}.bind(this); // .bind(this) is optional but useful if the local state is attached to the current object
messageManager.addMessageListener(callbackName, myCallback);
messageManager.sendAsyncMessage("my-addon-framescript-message", { callbackName: callbackName } );
Framescript:
let messageHandler = function(message) {
let responseData = { foo: "bar" };
sendAsyncMessage(message.data.callbackName, responseData);
};
addMessageListener("my-addon-framescript-message", messageHandler);
There's a real-world example here: https://github.com/luckyrat/KeeFox/commit/c50f99033d2d07068140438816f8cc5e5e290da9
It should be possible for Firefox to be improved to encapsulate this functionality in the built-in messageManager one day but in the mean-time this approach works well and with a surprisingly small amount of boiler-plate code.
in this snippet below. i add the callback before sendAsyncMessage('my-addon-id#jetpack:getChromeToolbarbuttonText'... as i know it will send back. Then I remove it after callback executes. I know I don't have to but just to kind of make it act like real callback, just to kind of show people, maybe it helps someone understand.
Frame:
/////// frame script
function CALLBACK_getChromeToolbarButtonText(val) {
removeMessageListner('my-addon-id#jetpack:getChromeToolbarButtonTextCallbackMessage', CALLBACK_getChromeToolbarButtonText); //remove the callback
var chromeBtnText = val;
if (chromeBtnText == 'blah') {
alert('tool is blah');
}
}
addMessageListener('my-addon-id#jetpack:getChromeToolbarButtonTextCallbackMessage', CALLBACK_getChromeToolbarButtonText); //add the callback
var chromeBtnText = sendAsyncMessage("my-addon-id#jetpack:getChromeToolbarButtonText", null);
Chrome:
////// chrome script
messageManager.addMessageListener("my-addon-id#jetpack:getChromeToolbarButtonText", listener);
function listener() {
var val = document.getElementById('myChromeToolbarButton').label.value;
sendAsyncMessage('my-addon-id#jetpack:getChromeToolbarButtonTextCallbackMessage',val);
}