Building Modern Cross Browser Web Extensions: Core Functionality, Storage and Permissions (Part 5)

  February 4, 2025

 #webextensions#typescript#react#wxt

In the previous post, we explored the background scripts and the messaging in extensions, as well as using @webext-core/messaging for a robust and type-safe messaging system. In this part, we will use the same concepts to build the core functionality of our extension.

Basic Commands

We want to add actions to the extension such as opening a new tab, a new window as well as state based actions such as muting/unmuting or pinning/unpinning a tab. Let’s follow the same pattern we explored in the previous post.

  1. First, we will define the types for the actions we want to add to the extension.
src/lib/types.ts
export type UserAction = {
  title: string;
  id: string;
  handler: () => void;
  visible: boolean;     // To hide actions such as mute/unmute conditionally
};
  1. Next, we will set up messaging in src/lib/messaging.ts as follows:
src/lib/messaging.ts

import { defineExtensionMessaging } from "@webext-core/messaging";

interface ProtocolMap {
  userAction(data: { type: "newTab" | "newWindow" }): void;
}

export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
  1. Create a new file src/lib/user.actions.ts to define the actions:
src/lib/user.actions.ts
import { sendMessage } from "@/lib/messaging";
import type { UserAction } from "@/lib/types";

export const userActions: UserAction[] = [
  {
    title: "New Tab",
    id: "newTab",
    handler: () => {
      sendMessage("userAction", { type: "newTab" });
    },
    visible: true,
  },
  {
    title: "New Window",
    id: "newWindow",
    handler: () => {
      sendMessage("userAction", { type: "newWindow" });
    },
    visible: true,
  },
];

Note that although this file is not explicitly marked as a content script, its actions will be utilized within the Command Palette, which itself is a part of our main content script.

  1. We will also add the listeners in the background script to handle these actions:
src/entrypoints/background.ts
import { onMessage } from "@/lib/messaging";

export default defineBackground(() => {
  onMessage("userAction", (userAction) => {
    switch (userAction.data.type) {
      case "newTab":
        browser.tabs.create({});
        break;
      case "newWindow":
        browser.windows.create({});
        break;
    }
  });
});
  1. Finally, we will use these actions in the Command Palette component:
src/components/CommandPalette.tsx
import * as React from "react";
import {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";

import { userActions } from "@/lib/user.actions";

export function CommandPalette() {
  const [open, setOpen] = React.useState(false);

  React.useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  return (
    <>
      <CommandDialog open={open} onOpenChange={setOpen}>
        <Command loop={true} className="max-h-96 min-h-96 rounded-lg shadow-md">
          <CommandInput placeholder="Type a command or search..." />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            {userActions.map((action) => (
              <CommandItem
                key={action.id}
                value={action.title}
                onSelect={() => {
                  action.handler();
                  setOpen(false);
                }}
              >
                <span className="flex-1 truncate px-3 text-start">
                  {action.title}
                </span>
              </CommandItem>
            ))}
          </CommandList>
        </Command>
      </CommandDialog>
    </>
  );
}

That’s all we need to do, and our actions should now be available in the Command Palette.

State Dependent Commands

We can also add actions that are dependent on the state of the tab, such as muting/unmuting or pinning/unpinning. Let’s add these actions to the extension.

  1. Add the Tab type to src/lib/types.ts:
src/lib/types.ts
export type Tab = chrome.tabs.Tab;
  1. We will need the current tab to get the state as well as to perform actions on it. Let’s update our messaging setup for the same:
src/lib/messaging.ts
import { defineExtensionMessaging } from "@webext-core/messaging";
import { Tab } from "@/lib/types";

interface ProtocolMap {
  userAction(data: {
    type: "newTab" | "newWindow" | "muteTab" | "unmuteTab";
    tab?: Tab; // We will need the tab info for updating its state
  }): void;
  getActiveTab(): Promise<Tab>;
}

export const { sendMessage, onMessage } =
  defineExtensionMessaging<ProtocolMap>();

We have added muteTab and unmuteTab actions, and an optional tab parameter to the userAction message. Whenever we want to use the browser APIs to update the state of a tab, we need to provide its unique id, which we can get from this tab parameter. We have also added a getActiveTab action to get the current active tab.

  1. Let’s update our background script to handle these actions:
src/entrypoints/background.ts
import { onMessage } from "@/lib/messaging";

export default defineBackground(() => {
  onMessage("userAction", (userAction) => {
    switch (userAction.data.type) {
    // ...listeners for newTab and newWindow actions remain the same

      case "muteTab":
        if (userAction.data.tab?.id) {
          browser.tabs.update(userAction.data.tab.id, { muted: true });
        }
        break;

      case "unmuteTab":
        if (userAction.data.tab?.id) {
          browser.tabs.update(userAction.data.tab.id, { muted: false });
        }
        break;
    }
  });

  onMessage("getActiveTab", async () => {
    const tabs = await browser.tabs.query({
      active: true,
      currentWindow: true,
    });
    return tabs[0];
  });
});

We have a separate listener for getActiveTab to get the current active tab, while muteTab and unmuteTab actions are under the userAction listener. This is in accordance with our messaging setup. Though we have not mentioned return type here for listener of getActiveTab, its return type will be Tab, which, again, is inferred from our messaging setup.

  1. In our content script, instead of directly returning userActions as an array, we will now use an async function, getUserActions, which will return actions based on the state of the tab:
src/lib/user.actions.ts
import { sendMessage } from "@/lib/messaging";
import type { UserAction } from "@/lib/types";

export const getUserActions = async (): Promise<UserAction[]> => {
  // Note that when we don't have any parameters, we have to pass undefined
  const activeTab = await sendMessage("getActiveTab", undefined);
  const isMuted: boolean = activeTab.mutedInfo?.muted || false;

  const userActions: UserAction[] = [
        // ...newTab and newWindow actions remain the same
    {
      title: "Mute Tab",
      id: "muteTab",
      handler: () => {
        sendMessage("userAction", { type: "muteTab", tab: activeTab });
      },
      visible: !isMuted,
    },
    {
      title: "Unmute Tab",
      id: "unmuteTab",
      handler: () => {
        sendMessage("userAction", { type: "unmuteTab", tab: activeTab });
      },
      visible: isMuted,
    },
  ];

  return userActions.filter((action) => action.visible);
};
  1. Since getUserActions is an async function, we need to update the Command Palette component to handle the promise:
src/components/CommandPalette.tsx

import * as React from "react";
import {
  Command,
  CommandDialog,
  CommandEmpty,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";

import { getUserActions } from "@/lib/user.actions";
import { UserAction } from "@/lib/types";

export function CommandPalette() {
  const [open, setOpen] = React.useState(false);
  const [userActions, setUserActions] = React.useState<UserAction[]>([]);

  React.useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  React.useEffect(() => {
    if (!open) return;
    getUserActions().then(setUserActions);
  }, [open]);

  return (
    <>
      <CommandDialog open={open} onOpenChange={setOpen}>
        <Command loop={true} className="max-h-96 min-h-96 rounded-lg shadow-md">
          <CommandInput placeholder="Type a command or search..." />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            {userActions.map((action) => (
              <CommandItem
                key={action.id}
                value={action.title}
                onSelect={() => {
                  action.handler();
                  setOpen(false);
                }}
              >
                <span className="flex-1 truncate px-3 text-start">
                  {action.title}
                </span>
              </CommandItem>
            ))}
          </CommandList>
        </Command>
      </CommandDialog>
    </>
  );
}

Now, the Command Palette will show the Mute Tab and Unmute Tab actions based on the state of the active tab. We can also filter visible actions in useEffect instead of filtering them in the getUserActions function, if desired. We can similarly add other conditional actions such as Pin Tab and Unpin Tab.

Adding Keybindings To The Command Palette

Let’s display keybindings for the actions. Later, we will provide a way for the user to toggle the visibility of keybindings using browser storage to store user preferences.

  1. First update our UserAction type to include a keybinding property:
src/lib/types.ts
export type UserAction = {
  title: string;
  id: string;
  handler: () => void;
  visible: boolean;
  keybinding?: string[];    //optional
};
  1. We can detect the OS using the browser.runtime.getPlatformInfo API and set the keybindings accordingly. This API can only be used in background script, so we will update our messaging setup, add listener to background script, and update the user.actions.ts file:
src/lib/messaging.ts
interface ProtocolMap {
  //...existing protocol map
  getPlatformInfo(): Promise<chrome.runtime.PlatformInfo>;
}
src/entrypoints/background.ts
export default defineBackground(() => {
  //...existing listeners
  onMessage("getPlatformInfo", async () => {
    return browser.runtime.getPlatformInfo();
  });
});
src/lib/user.actions.ts
import { sendMessage } from "@/lib/messaging";
import type { UserAction } from "@/lib/types";

export const getUserActions = async (): Promise<UserAction[]> => {
  const activeTab = await sendMessage("getActiveTab", undefined);
  const isMuted: boolean = activeTab.mutedInfo?.muted || false;
  const isPinned: boolean = activeTab.pinned || false;

  const platform = await sendMessage("getPlatformInfo", undefined);
  const isMac = platform.os === "mac";

  const userActions: UserAction[] = [
    {
      title: "New Tab",
      id: "newTab",
      handler: () => {
        sendMessage("userAction", { type: "newTab" });
      },
      visible: true,
      keybinding: ["Command", "T"],
    },
    {
      title: "New Window",
      id: "newWindow",
      handler: () => {
        sendMessage("userAction", { type: "newWindow" });
      },
      visible: true,
      keybinding: ["Command", "N"],
    },
    {
      title: "Mute Tab",
      id: "muteTab",
      handler: () => {
        sendMessage("userAction", { type: "muteTab", tab: activeTab });
      },
      visible: !isMuted,
      keybinding: isMac ? ["Command", "Shift", "M"] : ["Command", "M"],
    },
    {
      title: "Unmute Tab",
      id: "unmuteTab",
      handler: () => {
        sendMessage("userAction", { type: "unmuteTab", tab: activeTab });
      },
      visible: isMuted,
      keybinding: isMac ? ["Command", "Shift", "M"] : ["Command", "M"],
    },
    {
      title: "Pin Tab",
      id: "pinTab",
      handler: () => {
        sendMessage("userAction", { type: "pinTab", tab: activeTab });
      },
      visible: !isPinned,
    },
    {
      title: "Unpin Tab",
      id: "unpinTab",
      handler: () => {
        sendMessage("userAction", { type: "unpinTab", tab: activeTab });
      },
      visible: isPinned,
    },
  ];

  const visibleActions = userActions.filter((action) => action.visible);

  // change 'Command' to '⌘', Alt to '⌥' and Shift to '⇧' on Mac
  // 'Command' is 'Ctrl' on Windows/Linux
  const formattedActions = visibleActions.map((action) => {
    action.keybinding = action.keybinding?.map((key) => {
      if (key === "Command") {
        return isMac ? "⌘" : "Ctrl";
      } else if (key === "Alt") {
        return isMac ? "⌥" : "Alt";
      } else if (key === "Shift") {
        return isMac ? "⇧" : "Shift";
      }
      return key;
    });
    return action;
  });
  return formattedActions;
};
  1. Finally, render the keybindings in the Command Palette component:
src/components/CommandPalette.tsx
{userActions.map((action) => (
  <CommandItem
    key={action.id}
    value={action.title}
    onSelect={() => {
      action.handler();
      setOpen(false);
    }}
  >
    <span className="flex-1 truncate px-3 text-start">
      {action.title}
    </span>
    {action.keybinding && (
      <div className="flex items-center space-x-2">
        {action.keybinding.map((key, i) => (
          <div>
            <kbd
              key={key}
              className="px-2 py-1 text-foreground bg-accent rounded
                        text-sm text-center">
              {key}
            </kbd>
            <span>
              {action.keybinding?.length &&
              i < action.keybinding.length - 1
                ? "+"
                : ""}
            </span>
          </div>
        ))}
      </div>
    )}
  </CommandItem>
))}

The Command Palette will now show the keybindings based on the OS.

On Windows/Linux
On Mac

Note that the muteTab and unmuteTab actions have different keybindings for Mac and other platforms, which we have handled based on the isMac variable.

Storage

The browser’s asynchronous storage API provides persistent key-value storage for user preferences, extension state, and other application data. We can use the local storage, which is persistent across browser sessions, or the session storage, which is cleared when the browser is closed. We can also use the sync storage, which is synced across devices if the user is signed in to their browser account. Each type of storage has different limits on the amount of data it can store. The storage API is available in all extension contexts, including background scripts and content scripts. For more details, refer to the MDN Docs or Chrome Developer Docs. Before proceeding, you are encouraged to read these docs and understand the differences between using storage APIs and other storage methods such as localStorage or IndexedDB.

WXT provides a well designed wrapper around the browser storage APIs, with lots of features such as type safety, versioning, metadata, bulk operations, watchers and more, which makes it easier to work with storage in extensions. These are thoroughly documented in the WXT docs, so we will not go into details here.

Adding A Toggle To Show/Hide Keybindings

To demonstrate the use of storage API, we will add a toggle in the popup to show or hide keybindings in the Command Palette.

  1. We need to add the storage permission in our wxt.config.ts:
wxt.config.ts
import { defineConfig } from "wxt";

// See https://wxt.dev/api/config.html
export default defineConfig({
  srcDir: "src",
  extensionApi: "chrome",
  modules: ["@wxt-dev/module-react"],
  manifest: {
    name: "Command Palette",
    description: "A command palette to quickly perform actions",
    permissions: ["storage"],
  },
});
  1. We will add a new file src/lib/storage.ts to handle storage operations. We will use the defined storage from WXT:
src/lib/storage.ts

// Note the local: prefix, which is used to store the data in local storage
const showKeybindings = storage.defineItem<boolean>("local:showKeybindings", {
  defaultValue: true,
  fallback: true,
  version: 1,
});

export const store = {
  showKeybindings,
};

storage is auto imported here from @wxt-dev/storage. We have defined a new item showKeybindings with a default value of true, and we are using local storage for this item. For convenience, we have exported a single object store which contains all the storage items. If we have large number of storage items, instead of exporting them individually, we can export them as a single object.

  1. We will add a switch (from Shadcn) in our src/entrypoints/popup/App.tsx
src/entrypoints/popup/App.tsx
import { useState, useEffect } from "react";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { store } from "@/lib/storage";

function App() {
  const [showKeybindings, setShowKeybindings] = useState(true);

  useEffect(() => {
    store.showKeybindings.getValue().then(setShowKeybindings);
  }, []);

  return (
    <div className="m-3 w-72">
      <h1 className="text-lg font-bold mb-3">Settings</h1>
      <div className="flex items-center justify-between mb-3">
        <Label htmlFor="showKeybindings" className="font-normal">
          Show keybindings
        </Label>
        <Switch
          id="showKeybindings"
          checked={showKeybindings}
          onCheckedChange={(checked) => {
            store.showKeybindings.setValue(checked);
            setShowKeybindings(checked);
          }}
        ></Switch>
      </div>
    </div>
  );
}

export default App;
  1. In the Command Palette, all we have to do is check the value from storage and render the keybindings accordingly. We will update the existing useEffect:
src/components/CommandPalette.tsx
import { store } from "@/lib/storage";

export function CommandPalette() {
  const [showKeybindings, setShowKeybindings] = React.useState(true);
  //...existing states and effects

  React.useEffect(() => {
    if (!open) return;
    store.showKeybindings.getValue().then(setShowKeybindings);
    getUserActions().then(setUserActions);
  }, [open]);

Use the showKeybindings state to conditionally render the keybindings in the Command Palette:

src/components/CommandPalette.tsx
{action.keybinding && showKeybindings && (
  <div className="flex items-center space-x-2">
    {action.keybinding.map((key, i) => (
      <div>
        <kbd
          key={key}
          className="px-2 py-1 text-foreground bg-accent rounded text-sm text-center"
        >
          {key}
        </kbd>
        <span>
          {action.keybinding?.length &&
          i < action.keybinding.length - 1
            ? "+"
            : ""}
        </span>
      </div>
    ))}
  </div>
)}

The value is read from the storage when the Command Palette is opened, so if the user changes the setting in the popup, they will need to close and reopen the Command Palette to see the changes. We can add a watcher to the storage item to update the state in real time:

src/components/CommandPalette.tsx
React.useEffect(() => {
  if (!open) return;
  store.showKeybindings.getValue().then(setShowKeybindings);
  getUserActions().then(setUserActions);
  const unwatch = store.showKeybindings.watch(setShowKeybindings);
  return () => unwatch();
}, [open]);

Since the storage API is available in all extension contexts, we can perform storage operations directly without needing to use message passing or adding listeners in the background script.

Viewing The Stored Data

We can view the stored data in the browser directly. Note that the storage data will be empty if the default value of showKeybindings in the popup has not been changed.

  1. In Chrome, open the service worker DevTools (the same way we accessed the console for the background script), and go to the Application tab. You will see the Extension Storage section on the left, which will has different storage areas such as Local, Session, and Sync. Our stored data will be visible in the Local section.

Chrome Storage

  1. In Firefox, open the DevTools by going to about:debugging#/runtime/this-firefox and clicking on Inspect for your extension. Under Storage tab, and you will see the Extension Storage section on the left, which has the stored data.

Firefox Storage

This is a very simple demonstration, and WXT offers many more storage related-features which are not covered here. You can refer to the WXT storage docs for more details.

Permissions

So far, our actions were simple and did not require any permissions to be declared in the manifest.json (besides the storage). However, some actions might require additional permissions, such as adding or removing a bookmark. We have already seen an example of using the history permission in the previous post, and we can similarly add bookmark permission to our manifest. But this is not the best way of handling permissions, since the permissions listed in manifest are required to be granted during installation itself, which might put off the users if our extension needs sensitive permissions. A better way is to use minimum required permissions during installation, and add the rest as optional permissions in the manifest, which can be requested on runtime if needed. Let’s see how we can add actions that require permissions as well as requesting these permissions at runtime.

Adding And Removing Bookmarks With Install-Time Permissions

Let’s quickly add new actions to add and remove the current tab as a bookmark. We will also add a new required permission bookmarks in the wxt.config.ts for now (which we will later update to runtime permission):

wxt.config.ts
import { defineConfig } from "wxt";

// See https://wxt.dev/api/config.html
export default defineConfig({
  srcDir: "src",
  extensionApi: "chrome",
  modules: ["@wxt-dev/module-react"],
  manifest: {
    name: "Command Palette",
    description: "A command palette to quickly perform actions",
    permissions: ["storage","bookmarks"],
  },
});
src/lib/messaging.ts

interface ProtocolMap {
  userAction(data: {
    type:
      | "newTab"
      | "newWindow"
      | "muteTab"
      | "unmuteTab"
      | "pinTab"
      | "unpinTab"
      | "addBookmark"
      | "removeBookmark";

    tab?: Tab;
  }): void;
  isBookmarked(data: { tab: Tab }): Promise<boolean>;

    //...existing protocol map
src/lib/user.actions.ts
const isBookmarked = await sendMessage("isBookmarked", { tab: activeTab });

const userActions: UserAction[] = [
    //...existing actions
    {
      title: "Add Bookmark",
      id: "addBookmark",
      handler: () => {
        sendMessage("userAction", { type: "addBookmark", tab: activeTab });
      },
      visible: !isBookmarked,
      keybinding: ["Command", "D"],
    },
    {
      title: "Remove Bookmark",
      id: "removeBookmark",
      handler: () => {
        sendMessage("userAction", { type: "removeBookmark", tab: activeTab });
      },
      visible: isBookmarked,
      keybinding: ["Command", "D"],
    },
]
src/entrypoints/background.ts
  onMessage("userAction", (userAction) => {
    switch (userAction.data.type) {
      //..existing cases
      case "addBookmark":
        if (userAction.data.tab?.id) {
          browser.bookmarks.create({
            title: userAction.data.tab.title,
            url: userAction.data.tab.url,
          });
        }
        break;

      case "removeBookmark":
        if (userAction.data.tab?.id) {
          browser.bookmarks.search({ url: userAction.data.tab.url })
          .then((bookmarks) => {
            if (bookmarks.length) {
              browser.bookmarks.remove(bookmarks[0].id);
            }
          });
        }
        break;
    }
  });

  onMessage("isBookmarked", async (message) => {
    if (!message.data.tab?.url) return false;
    const bookmarks = await browser.bookmarks.search({
      url: message.data.tab.url,
    });
    return bookmarks.length > 0;
  });

Our add and remove bookmark actions should work now.

Conditionally Showing Actions Based On Permissions

The visibility of certain actions depends on the specific permissions being granted. If we don’t have the bookmarks permission, we should not show either of the ‘Add Bookmark’ and ‘Remove Bookmark’ actions, as they won’t work in that case. Before displaying such actions, we need to check the permissions, as well as provide a way for the user to grant them if they are listed as optional permissions. Let’s update wxt.config.ts to remove the bookmarks permission from the required permissions and add it to optional permissions:

wxt.config.ts
import { defineConfig } from "wxt";

// See https://wxt.dev/api/config.html
export default defineConfig({
  srcDir: "src",
  extensionApi: "chrome",
  modules: ["@wxt-dev/module-react"],
  manifest: {
    name: "Command Palette",
    description: "A command palette to quickly perform actions",
    permissions: ["storage"],
    optional_permissions: ["bookmarks"],
  },
});

Again we need to update types and the messaging setup:

src/lib/types.ts
export type Permissions = chrome.permissions.Permissions;
src/lib/messaging.ts
interface ProtocolMap {
  //...existing protocol map
  getPermissions(): Promise<Permissions>;
}
src/entrypoints/background.ts
  onMessage("getPermissions", async () => {
    return browser.permissions.getAll();
  });

Consider the scenarios under which we want the ‘Add Bookmark’ and ‘Remove Bookmark’ to be visible:

Has permissionIs BookmarkedShow ‘Add’ actionShow ‘Remove’ action
NoNoNoNo
NoYesNoNo
YesNoYesNo
YesYesNoYes

In simpler words, if we don’t have the permission, we won’t show either of the options. If we have the permission, we will check if the page is bookmarked or not and show the corresponding action, so we need to check both the parameters here. Update the user.actions.ts file to include this logic:

src/lib/user.actions.ts
import type { Tab, UserAction } from "@/lib/types";

const getBookmarkStatus = async (
  tab: chrome.tabs.Tab,
): Promise<[hasBookmarksPermission: boolean, isBookmarked: boolean]> => {
  const permissionsInfo = await sendMessage("getPermissions", undefined);
  const hasBookmarksPermission =
    permissionsInfo.permissions?.includes("bookmarks");

  // If we don't have the permission, we won't show either of the options,
  if (!hasBookmarksPermission) {
    return [false, false];
  }
  const isBookmarked = await sendMessage("isBookmarked", { tab });
  return [hasBookmarksPermission, isBookmarked];
};


export const getUserActions = async (): Promise<UserAction[]> => {
  //...existing checks
  const [hasBookmarksPermission, isBookmarked] = await getBookmarkStatus(activeTab);

  const userActions: UserAction[] = [
    //...existing actions

    {
      title: "Add Bookmark",
      id: "addBookmark",
      handler: () => {
        sendMessage("userAction", { type: "addBookmark", tab: activeTab });
      },
      visible: hasBookmarksPermission && !isBookmarked,
      keybinding: ["Command", "D"],
    },
    {
      title: "Remove Bookmark",
      id: "removeBookmark",
      handler: () => {
        sendMessage("userAction", { type: "removeBookmark", tab: activeTab });
      },
      visible: hasBookmarksPermission && isBookmarked,
      keybinding: ["Command", "D"],
    },
];

We have added a new function getBookmarkStatus to check the discussed conditions. However, both of our bookmark actions won’t be visible as of now, as we have bookmarks as the optional permission and it hasn’t been granted by the user yet.

Requesting Permissions At Runtime

Requesting permissions is quite simple – we just have to add a way for user to trigger the request. Let’s add a button in our popup through which user can request the permission:

src/entrypoints/popup/App.tsx
import { useState, useEffect } from "react";
import { Switch } from "@/components/ui/switch";
import { store } from "@/lib/storage";
import { Label } from "@/components/ui/label";
import { sendMessage } from "@/lib/messaging";

function App() {
  const [showKeybindings, setShowKeybindings] = useState(true);
  const [hasBookmarkPermission, setHasBookmarkPermission] = useState(false);

  useEffect(() => {
    store.showKeybindings.getValue().then(setShowKeybindings);
  }, []);

  useEffect(() => {
    const onload = async () => {
      const permissionsInfo = await sendMessage("getPermissions", undefined);
      //permissionsInfo contains permissions as well as origins
      // we only need permissions here
      if (permissionsInfo.permissions?.includes("bookmarks")) {
        setHasBookmarkPermission(true);
      }
    };
    onload();
  }, []);

  const requestBookmarksPermission = async () => {
    if (!hasBookmarkPermission) {
      const granted = await browser.permissions.request({
        permissions: ["bookmarks"],
      });

      if (granted) {
        setHasBookmarkPermission(true);
      }
    }
  };

  return (
    <div className="m-3 w-72 flex flex-col">
      <h1 className="text-lg font-bold mb-3">Settings</h1>
      <div className="flex items-center justify-between mb-3">
        <Label htmlFor="showKeybindings" className="font-normal">
          Show keybindings
        </Label>
        <Switch
          id="showKeybindings"
          checked={showKeybindings}
          onCheckedChange={(checked) => {
            store.showKeybindings.setValue(checked);
            setShowKeybindings(checked);
          }}
        ></Switch>
      </div>
      {hasBookmarkPermission ? (
        <span className="text-sm text-wrap mr-2">
          Bookmarks permission granted
        </span>
      ) : (
        <div className="flex items-center justify-between">
          <span className="text-sm text-wrap mr-2">
            Request bookmarks permission(required for adding/removing bookmarks)
          </span>
          <Button className="font-normal" onClick={requestBookmarksPermission}>
            Request
          </Button>
        </div>
      )}
    </div>
  );
}
export default App;

We initially checked if the permission is already granted, and if it isn’t, we show a button to request the permission. On button click, we request the permission using browser.permissions.request and update the state accordingly.

Warning

Important note for Firefox: Permissions can only be requested via direct user interaction, such as a button click, and not programmatically. This is to prevent extensions from requesting permissions without user consent. The permission request must be the first thing on the button click. If you add any other code before the permission request, the request will be blocked. For example, try adding a simple timeout before the permission request and you should get an error on the background script’s console:

src/entrypoints/popup/App.tsx
 const requestBookmarksPermission = async () => {

   await new Promise((resolve) => setTimeout(resolve, 1000));

   if (!hasBookmarkPermission) {
     const granted = await browser.permissions.request({
       permissions: ["bookmarks"],
     });

     if (granted) {
       setHasBookmarkPermission(true);
     }
   }
 };

Firefox Permissions Error

This also means you cannot ask for multiple permissions at once on a single button click (this might also be problematic in chrome). If you need to ask for multiple permissions, the best way would be to ask for them one by one separately.

Making The Shortcut Configurable

We have been listening to keyboard events in our Command Palette component to toggle its visibility. We can move this to extension level rather than having it in the UI component. This will allow us to make the shortcut configurable by the user.

First we need to add the suggested keybinding to the manifest in wxt.config.ts:

wxt.config.ts
import { defineConfig } from "wxt";

// See https://wxt.dev/api/config.html
export default defineConfig({
  srcDir: "src",
  extensionApi: "chrome",
  modules: ["@wxt-dev/module-react"],
  manifest: {
    name: "Command Palette",
    description: "A command palette to quickly perform actions",
    permissions: ["storage"],
    optional_permissions: ["bookmarks"],
    commands: {
      toggleMainDialog: {  // This is name of the command which we will listen to
        suggested_key: {
          default: "Alt+J", // Ctrl+J is taken by Chrome/Firefox
        },
        description: "Toggle the main dialog",
      },
    },
  },
});

Next, add a message that can be sent from the background script to the content script to toggle the dialog:

src/lib/messaging.ts
interface ProtocolMap {
  //...existing protocol map
  toggleMainDialog(): void;
}

Add a listener for the command in the background script (the command API is not accessible in content scripts), and send a message to the content script to toggle the dialog:

src/entrypoints/background.ts

import { onMessage, sendMessage } from "@/lib/messaging";

export default defineBackground(() => {
  //...existing listeners
  browser.commands.onCommand.addListener((command) => {
    if (command === "toggleMainDialog") {   // same name as in the manifest

    // send the message to the content script of active tab
      browser.tabs
        .query({
          active: true,
          currentWindow: true,
        })
        .then((tabs) => {
          if (tabs[0]?.id) {
            // tab id is required to send message to the content script
            sendMessage("toggleMainDialog", undefined, tabs[0].id);
          }
        });
    }
  });

On receiving the message in the Command Palette component, simply update the state to toggle the dialog:

src/components/CommandPalette.tsx

import { onMessage } from "@/lib/messaging";

export function CommandPalette() {

    //...existing code
  React.useEffect(() => {

    // onMessage returns a function to remove the listener, which we can call in the cleanup function
    const removeListener = onMessage("toggleMainDialog", () => {
      setOpen((open) => !open);
    });
    return () => removeListener();
  }, []);


  // We can remove the keyboard event listener
  // React.useEffect(() => {
  //   const down = (e: KeyboardEvent) => {
  //     if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
  //       e.preventDefault();
  //       setOpen((open) => !open);
  //     }
  //   };
  //
  //   document.addEventListener("keydown", down);
  //   return () => document.removeEventListener("keydown", down);
  // }, []);

  //.. rest of the code as is

Our default shortcut is now Alt+J, which user can change from the browser’s extension settings.

Configurable Shortcut in Chrome
Configurable Shortcut in Firefox

Conclusion

Through this series, we have covered the fundamentals of building modern web extensions using WXT. To recall, we have covered the following topics:

  1. Fundamentals of Web Extensions, the current state of extension development, and the need for a modern approach.
  2. Setting up a new project with WXT, Tailwind CSS, and Shadcn.
  3. Working with Content Scripts, building isolated UIs, and fixing some common issues with UI.
  4. Background Scripts, built in messaging APIs, and using an external wrapper for messaging.
  5. Using Storage API, Permissions / Runtime Permissions, and Commands.

This builds a strong foundation for building more complex extensions. We have covered a lot of ground, but there is still a lot more to explore:

  1. Other Entrypoints: Though we have used only the content script and Popup, there are other entrypoints like Sidebar, Devtools, and Options page which you can use in your extension.
  2. Publishing: Different stores have different processes for publishing extensions. After initial publishing, WXT provides a way to automate the process of updating the extension. To use the extensions in release build of browser, you need a signed version which can be obtained from respective stores. You can also automate publishing using Github Actions.
  3. Testing: WXT has support for Vitest for unit testing and suggests using Playwright for end-to-end testing.
  4. Internationalization: WXT has a package @wxt-dev/i18n for internationalization, which can be used to localize the extension based on user’s preferred language.

Finally, keep an eye on this space for future posts. If you have any questions or suggestions, feel free to reach out at [email protected]

Last updated on