Search code examples
google-chrome-extensionchrome-extension-manifest-v3

Downloading a large Blob to local file in ManifestV3 service worker


I have a logging mechanism in place that saves the logs into an array. And I need a way to download the logs into a file.

I had this previously working (on manifest v2) with

const url = URL.createObjectURL(new Blob(reallyLongString, { type: 'text/plain' }));
const filename = 'logs.txt';
chrome.downloads.download({url, filename});

Now I am migrating to manifest v3 and since manifest v3 does not have URL.createObjectURL, you cannot create a url to pass to chrome.downloads.download

Instead it is possible to create a Blob URL using something like

const url = `data:text/plain,${reallyLongString}`; 
const filename = 'logs.txt';
chrome.downloads.download({url, filename});

The problem is that chrome.downloads.download seems to have a limit on the number of characters passed in the url argument, and the downloaded file only contains a small part of the string.

So what would be a way to overcome this limitation?


Solution

  • Hopefully, a way to download Blob directly in service worker will be implemented in https://crbug.com/1224027.

    Workaround via Offscreen document

    Example: https://stackoverflow.com/a/77427098

    It uses chrome.offscreen API to start an invisible DOM page where we can call URL.createObjectURL, pass the result back to SW that will use it for chrome.downloads.download.

    Workaround via an extension page

    Here's the algorithm:

    1. Use an already opened page such as popup or options
    2. Otherwise, inject an iframe into any page that we have access to
    3. Otherwise, open a new minimized window
    async function downloadBlob(blob, name, destroyBlob = true) {
      // When `destroyBlob` parameter is true, the blob is transferred instantly,
      // but it's unusable in SW afterwards, which is fine as we made it only to download
      const send = async (dst, close) => {
        dst.postMessage({blob, name, close}, destroyBlob ? [await blob.arrayBuffer()] : []);
      };
      // try an existing page/frame
      const [client] = await self.clients.matchAll({type: 'window'});
      if (client) return send(client);
      const WAR = chrome.runtime.getManifest().web_accessible_resources;
      const tab = WAR?.some(r => r.resources?.includes('downloader.html'))
        && (await chrome.tabs.query({url: '*://*/*'})).find(t => t.url);
      if (tab) {
        chrome.scripting.executeScript({
          target: {tabId: tab.id},
          func: () => {
            const iframe = document.createElement('iframe');
            iframe.src = chrome.runtime.getURL('downloader.html');
            iframe.style.cssText = 'display:none!important';
            document.body.appendChild(iframe);
          }
        });
      } else {
        chrome.windows.create({url: 'downloader.html', state: 'minimized'});
      }
      self.addEventListener('message', function onMsg(e) {
        if (e.data === 'sendBlob') {
          self.removeEventListener('message', onMsg);
          send(e.source, !tab);
        }
      });
    }
    

    downloader.html:

    <script src=downloader.js></script>
    

    downloader.js, popup.js, options.js, and other scripts for extension pages (not content scripts):

    navigator.serviceWorker.ready.then(swr => swr.active.postMessage('sendBlob'));
    navigator.serviceWorker.onmessage = async e => {
      if (e.data.blob) {
        await chrome.downloads.download({
          url: URL.createObjectURL(e.data.blob),
          filename: e.data.name,
        });
      }
      if (e.data.close) {
        window.close();
      }
    }
    

    manifest.json:

    "web_accessible_resources": [{
      "matches": ["<all_urls>"],
      "resources": ["downloader.html"],
      "use_dynamic_url": true
    }]
    

    Warning! Since "use_dynamic_url": true is not yet implemented don't add web_accessible_resources if you don't want to make your extension detectable by web pages.