-
Notifications
You must be signed in to change notification settings - Fork 281
SubPanel API
- Abstract
- How to register a subpanel
- How to implement a subpanel
- Drag and drop between your subpanel and TST
- How to provide custom context menu on your subpanel
(generated by Table of Contents Generator for GitHub Wiki)
Important Notes:
- This document assumes that you have experienced to develop one ore mroe Firefox extensions. If you've never done it, I recommend you to see the basic document for extensions authors.
- This very experimental API was initially created to demonstrate: 1) how WebExtensions extension is restricted to provide this kind feature, and 2) only a genuine WebExtensions feature can satisfy such a demand. Please remind that subpanels provided via this API have many restriction, as described at TST Bookmarks Subpanel's distribution page.
You cannot show multiple sidebar panels on Firefox 57 and later. (See also: 1328776 - Provide ability to show multiple sidebar contents parallelly) This is a largely known "regression" of TST2 from legacy versions. A new TST API "SubPanel API" is a workaround to solve this problem. With this feature, you can embed arbitrary contents into TST's sidebar on Tree Style Tab 3.1.0 and later.
Here is a figure to describe relations around a subpanel page:
There is a reference implementation of a subpanel: TST Bookmarks Subpanel. It is a clone of Firefox's bookmarks sidebar for Tree Style Tab's subpanel.
Your addon can register only one subpanel with the register-self
message. Here is an example:
const TST_ID = '[email protected]';
async function registerToTST() {
try {
await browser.runtime.sendMessage(TST_ID, {
type: 'register-self',
name: browser.i18n.getMessage('extensionName'),
icons: browser.runtime.getManifest().icons,
subPanel: {
title: 'Panel Name',
url: `moz-extension://${location.host}/path/to/panel.html`,
},
listeningTypes: ['wait-for-shutdown'],
});
}
catch(_error) {
// TST is not available
}
}
// This is required to remove the subpanel you added on uninstalled.
const promisedShutdown = new Promise((resolve, reject) => {
window.addEventListener('beforeunload', () => resolve(true));
});
browser.runtime.onMessageExternal.addListener((message, sender) => {
switch (sender.id) {
case TST_ID:
switch (message.type) {
// TST is initialized after your addon.
case 'ready':
registerToTST();
break;
// This is required to remove the subpanel you added on uninstalled.
case 'wait-for-shutdown'
return promisedShutdown;
}
break;
}
});
registerToTST(); // Your addon is initialized after TST.
Please note that the sent message has an extra parameter subPanel
. It should be an object containing following properties:
-
title
(required): the title of the subpanel. -
url
(required): the URL of the subpanel page. -
initialHeight
(optional, a CSS length string or a number): the initial height of the subpanel contents. (available on TST 3.7.5 and later) -
fixedHeight
(optional, a CSS length string or a number): the fixed height of the subpanel contents. TST will make the subpanel un-resizable if this parameter is given. (available on TST 3.7.5 and later)
When these parameter exist, TST automatically loads the URL into an inline frame embedded in TST's sidebar panel.
And, listening of wait-for-shutdown
type notification is required for uninstallation.
Subpanel page is loaded into an inline frame. You can load any script from the document, but those scripts are executed with quite restricted permissions, in particular limited WebExtensions API allowed for content scripts are just available. And you cannot access to the parent frame (TST's contents) due to the same origin policy. So basically you'll need to implement things like:
- The background page: Similar to a server process. It will call WebExtensions API based on requests from the frontend, and returns the result.
- The subpanel page: Similar to a frontend webpage. It handles user interactions and send requests to the background page.
You simply need to use runtime.sendMessage()
. You can receive responses as per usual, like following example:
// in a subpanel page
browser.runtime.sendMessage({ type: 'get-bookmarks' }).then(bookmarks => {
// render contents based on the response
});
browser.runtime.sendMessage({ type: 'get-stored-value', key: 'foo' }).then(data=> {
// ...
});
// in the background page
browser.runtime.onMessage.addListener((message, sender) => {
switch (message.type) {
case 'get-bookmarks':
// This API returns a promise, so you just need return it.
return browser.bookmarks.getTree();
case 'get-stored-value':
// If you can construct a response synchronously,
// you need to wrap it as a promise before returning.
return Promise.resolve(store[message.key]);
}
});
On the other hand, reversed direction messaging is hard a little. You can register listeners for runtime.onMessage
on a subpanel page, but those listeners won't receive any message. Even if you send messages from the background page with runtime.sendMessage()
, you'll just see an exception like: Error: Could not establish connection. Receiving end does not exist.
Instead, you need to use runtime.onConnect
and runtime.Port.sendMessage()
.
First, you register a listener for runtime.onConnect
in the background page:
// in the background page
const connections = new Set();
browser.runtime.onConnect.addListener(port => {
// This callback is executed when a new connection is established.
connections.add(port);
port.onDisconnect.addListener(() => {
// This callback is executed when the client script is unloaded.
connections.delete(port);
});
});
function broadcastMessage(message) {
for (const connection of connections) {
connection.sendMessage(message);
}
}
And, you connect to the background page with runtime.connect()
from a subpanel page:
// in a subpanel page
// connect to the background page
const connection = browser.runtime.connect({
name: `panel:${Date.now()}` // this ID must be unique
});
connection.onMessage.addListener(message => {
// handling of broadcasted messages from the background page
//...
});
(This mechanism is available on TST 3.5.4 and later.)
Due to security reasons, drag data in a DataTransfer
object cannot be transferred across addon's namespaces. This means:
- Any drag data from your subpanel page bundled to a drag event via
event.dataTransfer.setData()
cannot be read from TST - it always return a blank string. - Any drag data from TST cannot be read from your subpanel page via
event.dataTransfer.getData()
- a blank string will be returned always on this case too.
As a workaround, TST supports a special data type application/x-treestyletab-drag-data
. It helps your subpanel addon to support drag and drop with TST.
Important Note: On TST 3.8.21 and later you need to use application/x-treestyletab-drag-data
instead of application/x-moz-addon-drag-data
. This change was introduced due to a security restrictions introduced at Firefox 97.
First, set a drag data with the type application/x-treestyletab-drag-data
with some parameters like following:
// Define these variables here to clear after
// the drag session finishes.
let dragData;
let dragDataId;
document.addEventListener('dragstart', event => {
...
dragData = {
'text/x-moz-url': 'http://example.com/\nExample',
'text/plain': 'http://example.com/',
};
// Generate a nonce as a drag session id.
// This is not required but it should guard your
// actual drag data from any data stealing attack.
dragDataId = `${parseInt(Math.random() * 1000)}-${Date.now()}`;
// Define one-time data type with parameters "provider" and "id".
const specialDragDataType = `application/x-treestyletab-drag-data;provider=${browser.runtime.id}&id=${dragDataId}`;
const dt = event.dataTransfer;
for (const type in dragData) {
dt.setData(type, lastDragData[type]);
}
// Set a blank drag data with the one-time data type.
// Please note that it cannot be read from outside,
// even if you set any effective data with the type.
dt.setData(sepcialDragDataType, '');
...
});
Important Note: On TST 3.8.21 and later you need to use application/x-treestyletab-drag-data
instead of application/x-moz-addon-drag-data
. This change was introduced due to a security restrictions introduced at Firefox 97.
When TST detects the drag data type, it tries to ask the actual drag data to your addon with a cross-addon message. The message will be an object like { type: 'get-drag-data', id: '(id string bundled to the drag data type as the "id" parameter)' }
. So, you should respond to the request by a listener of browser.runtime.onMessageExternal
, like following:
browser.runtime.onMessageExternal.addListener((message, _sender) => {
switch (message && typeof message.type == 'string' && message.type) {
case 'get-drag-data':
// You should respond to the request only when it has the corresponding id.
// This is a mechanism to guard your drag data from data stealing.
if (dragData &&
message.id == dragDataId) {
// The response message should be an object.
// Its keys should be data types, and the value should
// be the data corresponding to the type, like:
// { 'text/x-moz-url': '...',
// 'text/plain': '...' }
return Promise.resolve(dragData);
}
break;
}
});
document.addEventListener('dragend', event => {
// Clear data with delay, after the drop receiver successfully
// gets the actual drag data.
setTimeout(() => {
dragData = null;
dragDataId = null;
}, 200);
});
This is the basics. But you'll realisze that browser.runtime.onMessageExternal
is not accessible in a subpanel page. It looks to be restricted in an iframe, so actually you need to register a listener on a background script. This means that you need to transfer the drag data from the subpanel page to the background page on every drag event, like following:
// ====================================================
// The background script
// ====================================================
let dragData;
let dragDataId;
browser.runtime.onMessage.addListener((message, _sender) => {
switch (message && typeof message.type == 'string' && message.type) {
...
case 'set-drag-data':
dragData = message.data || null;
dragDataId = message.id || null;
break;
...
}
});
browser.runtime.onMessageExternal.addListener((message, _sender) => {
switch (message && typeof message.type == 'string' && message.type) {
case 'get-drag-data':
if (dragData &&
message.id == dragDataId)
return Promise.resolve(dragData);
break;
}
});
// ====================================================
// The script running on the subpanel
// ====================================================
document.addEventListener('dragstart', event => {
...
const dragData = {
'text/x-moz-url': 'http://example.com/\nExample',
'text/plain': 'http://example.com/',
};
const dragDataId = `${parseInt(Math.random() * 1000)}-${Date.now()}`;
const specialDragDataType = `application/x-treestyletab-drag-data;provider=${browser.runtime.id}&id=${dragDataId}`;
const dt = event.dataTransfer;
for (const type in dragData) {
dt.setData(type, lastDragData[type]);
}
dt.setData(sepcialDragDataType, '');
browser.runtime.sendMessage({
type: 'set-drag-data',
data: dragData,
id: dragDataId,
});
...
});
document.addEventListener('dragend', event => {
setTimeout(() => {
browser.runtime.sendMessage({
type: 'set-drag-data',
data: null,
id: null,
});
}, 200);
});
TST also transfers the drag data in the way same to above. Here is an example implementation to retrieve effective drag data safely:
const ACCEPTABLE_DRAG_DATA_TYPES = ['text/plain'];
document.addEventListener('drop', async event => {
const dt = event.dataTransfer;
let retrievedData;
// First, you should try the genuine way.
for (const type of ACCEPTABLE_DRAG_DATA_TYPES) {
const data = dt.getData(type);
if (data) {
retrievedData = data;
break;
}
}
// If it fails, fallback to the method based on the special data type.
if (!retrievedData) {
for (const type of dt.types) {
// Check there is any drag data with the type "application/x-treestyletab-drag-data".
if (!/^application\/x-treestyletab-drag-data;(.+)$/.test(type))
continue;
// If found, parse the parameters to extract provider's ID
// and the drag session ID.
const params = RegExp.$1;
const providerId = /provider=([^;&]+)/.test(params) && RegExp.$1;
const dataId = /id=([^;&]+)/.test(params) && RegExp.$1;
try {
const dragData = await browser.runtime.sendMessage(providerId, {
type: 'get-drag-data',
id: dataId // This is required to get the data safely.
});
// If you got the drag data successfully, it should be
// an object and its keys are data types, and the value
// is the data corresponding to the type, like:
// { 'text/x-moz-url': '...',
// 'text/plain': '...' }
if (dragData && typeof dragData == 'object') {
for (const type of ACCEPTABLE_DRAG_DATA_TYPES) {
const data = dragData[type];
if (data) {
retrievedData = data;
break;
}
}
}
}
catch(_error) {
// runtime.sendMessage() fails when the receiver addon is missing.
}
}
}
console.log(retrievedData);
...
}
});
(This mechanism is available on TST 3.5.4 and later.)
On Firefox 64 and later, a sidebar addon can provide custom context menu with the API browser.menus.overrideContext()
. But it is unavailable on your subpanel page due to Firefox's security restriction (indeed it is not listed in the list of available API in a content script). So, in a subpanel you need to use TST's API instead.
First, create your custom context menu items via the Fake Context Menu API. See the linked document for basics - about creating, updating, and handling click on menu items. And there is one addition: add the parameter viewTypes: ['sidebar']
. For example, if you want to implement a bookmark related menu item:
browser.runtime.sendMessage(TST_ID, {
type: 'contextMenu-create',
{
id: 'open-bookmark',
type: 'normal',
title: 'Open Bookmark',
contexts: ['bookmark'],
viewTypes: ['sidebar'], // <= This is required!
}
}).catch(error => { /* TST is not available */ });
When a fake context menu item is defined with viewTypes: ['sidebar']
, TST treats it as a top-level context menu item on your subpanel, like Firefox does. Oppose to the browser.menus.create()
case, you don't need to specify any documentUrlPatterns
.
And please remind that you need to create menu items again when TST is reloaded while your subpanel provider addon is running.
Second, send a message with the type override-context
(yes, it corresponds to browser.menus.overrideContext()
) to TST when the mouse button is down and the context menu is going to be opened. For example:
window.addEventListener('mousedown', event => {
const item = event.target;
// The context menu trigger is:
// Windows and Linux => the right mouse button.
// macOS => the right mouse button, double finger tap, or
// the left mouse button with Control key.
if (event.button == 2 ||
(/^Mac/i.test(navigator.platform) &&
event.button == 0 &&
event.ctrlKey)) {
browser.runtime.sendMessage(TST_ID, {
type: 'override-context',
context: 'bookmark',
bookmarkId: item.value, // assme that it is a bookmark ID.
windowId: 1,
});
}
}, { capture: true });
An override-context
type message will have following parameters:
-
context
(required, string):tab
orbookmark
. (Corresponds to acontext
for acontextOptions
) -
bookmarkId
(optional, string): an ID of a bookmark item. (Corresponds to abookmarkId
for acontextOptions
) -
tabId
(optional, integer): an ID of a tab. (Corresponds to atabId
for acontextOptions
) -
windowId
(required, integer): the ID of the window containing the sidebar (and your subpanel).
A window ID can be fetched with a script browser.windows.getCurrent({})
, but the API is unavailable on a subpanel due to Firefox's security system, so you may need to fetch it on the background script. For example:
// ====================================================
// The background script
// ====================================================
browser.runtime.onMessage.addListener((message, _sender) => {
switch (message && typeof message.type == 'string' && message.type) {
...
case 'get-current-window-id':
return browser.windows.getCurrent({}).then(window => window.id);
...
}
});
// ====================================================
// The script running on the subpanel
// ====================================================
// Fetch and store the window ID at first.
let windowId;
browser.runtime.sendMessage({ type: 'get-current-window-id' }).then(id => {
windowId = id;
});
window.addEventListener('mousedown', event => {
const item = event.target;
if (event.button == 2 ||
(/^Mac/i.test(navigator.platform) &&
event.button == 0 &&
event.ctrlKey)) {
browser.runtime.sendMessage(TST_ID, {
type: 'override-context',
context: 'bookmark',
bookmarkId: item.value,
windowId,
});
}
}, { capture: true });
(Note: This operation generally succeeds, but will fail on some environment. When any global gesture utility like the AutoHotKey is running and it is configured to trigger gestures starting with the right mouse button, such a utility will block dispatching of mousedown
event when the mouse button is pressed down, and re-dispatch it when the button is released. To avoid such a conflict you need to undefine gestures starting with the right-mouse-button-down from the utility.)
Finally you need to do one more thing: change the context menu trigger event from mousedown
to mouseup
for macOS and Linux. You can do it via browser.browserSettings.contextMenuShowEvent
, for example:
// in the background script
browser.browserSettings.contextMenuShowEvent.set({
value: 'mouseup',
});
The API requires the browserSettings
permission, so please add it to the list of permissions in the manifest.json
, like:
{
...
"permissions": [
...
"browserSettings",
...
],
...
}
Note: Even if you do that, Control-left-click to open won't open any overridden context menu on macOS. This restriction is confirmed with Firefox 75 and Nightly 77. Despite the above setting is applied, the context menu still opens on mousedown for a Control-left-click. Instead a two-or-more buttons mouse or a tap on the trackpad with two fingers (by default this gesture simulates a right-click) will open the overridden context menu successfully.
In short: TST mediates menu related API messages between your subpanel provider addon and Firefox.
Because the subpanel is treated as a part of TST, context menu items created with browser.menus.create({ viewTypes: ['sidebar'], documentUrlPatterns: ['moz-extension://(the internal ID issued for your addon)/*'] })
won't be shown on your subpanel.
Moreover, even if you create a menu item with documentUrlPatterns: ['moz-extension://(the internal ID issued for TST)/*']
, those items will be grouped under a submenu and won't become top-level items, because the context menu on TST's sidebar is controlled by TST. In other words, top-level items for the context menu on TST's sidebar including the subpanel area are always need to be created by TST itself.
This is the reason why you need to use TST's Fake Context Menu API. TST creates top-level menu items based on those API calls, and when TST receives click event on those menu items TST sends event information to your addon via its API.
Basically TST cannot handle the context menu on an iframe
. If you try to open the context menu on a subpanel, you'll see the native one for subframes provided by Firefox itself ("Save Page As..." and others). So, the context on an iframe
needs to be overridden with following scenario:
- You press the right mouse button.
- TST sets
pointer-events:none
for the iframe. - You release the mouse button.
- The event is dispatched to TST instead of the contents of the iframe.
- TST successfully calls
menus.overrideContext()
. - Firefox shows the context menu with custom menu items defined by TST.
This is impossible on macOS and Linux, because the context menu is shown with a mousedown event on those platforms.
- To override the context, TST needs to call
menus.overrideContext()
before the menu is shown. - But the mousedown event at the step 1 is dispatched only for the iframe contents (your subpanel).
To handle mouse events on the iframe TST needs to set pointer-events: none
to the iframe when the panel is initially shown - before the context menu is triggered. Then all mouse events on the subpanel area will be handled by TST itself, and TST need to proxy them to the subpanel contents. But only few cases can be proxied (for example events on native scrollbars won't be reproduced), so this approach definitly introduces bad user experience on the subpanel area.
Thus I decided to give up to do things based on the default behavior on all platforms. Instead I decided to request changing of browser's setting on non-Windows environment. Indeed the feature browser.browserSettings.contextMenuShowEvent
was initially introduced to help implementing mouse gesture addons overriding the native context menu behavior. I believe that my usecase is completely valid for the purpose of the API.
And, in this process TST calls browser.menus.overrideContex()
by a request from a subpanel addon, even if the menus.overrideContext
is not listed in the permissions of the addon itself. I believe this is not a illegal bypass of the security model, because the effect is enclosed inside the TST's sidebar panel and no effect at other scenes.