Skip to content
YUKI "Piro" Hiroshi edited this page Apr 28, 2020 · 47 revisions

(generated by Table of Contents Generator for GitHub Wiki)

Abstract

Important Note: 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.

How to register a subpanel

Your addon can register only one subpanel with the register-self message. Here is an example:

const TST_ID = 'treestyletab@piro.sakura.ne.jp';

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 two properties title and url. When the parameter exists, 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.

How to implement a subpanel

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.

Messaging from a subpanel page 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]);
  }
});

Messaging from the background page to a subpanel page

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.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
  //...
});

Drag and drop between subpanel and TST

(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.
  • Any drag data from TST cannot be read from your subpanel page via event.dataTransfer.getData().

As a workaround, TST supports a special data type application/x-moz-addon-drag-data. It helps your subpanel addon to support drag and drop with TST.

Transfer drag data from a subpanel to TST

First, set a drag data with the type application/x-moz-addon-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-moz-addon-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, '');
  ...
});

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 indeed you need to register a listener on a background script. This means that you need to transfear 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-moz-addon-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);
});

Retrieve the drag data from TST, on a subpanel

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-moz-addon-drag-data".
      if (!/^application\/x-moz-addon-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);
  ...
}

});

Known restrictions

Due to security reasons or limitations of WebExtensions API itself, there are some restrictions in your subpanel page:

  • Impossible to open native context menu for bookmarks and tabs, because menus.overrideContext() is unavailable on your subpanel page.
Clone this wiki locally