Building Modern Cross Browser Web Extensions: Core Functionality, Storage and Permissions (Part 5)
#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.
- First, we will define the types for the actions we want to add to the extension.
export type UserAction = {
title: string;
id: string;
handler: () => void;
visible: boolean; // To hide actions such as mute/unmute conditionally
};
- Next, we will set up messaging in
src/lib/messaging.ts
as follows:
import { defineExtensionMessaging } from "@webext-core/messaging";
interface ProtocolMap {
userAction(data: { type: "newTab" | "newWindow" }): void;
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
- Create a new file
src/lib/user.actions.ts
to define the actions:
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.
- We will also add the listeners in the background script to handle these actions:
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;
}
});
});
- Finally, we will use these actions in the Command Palette component:
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.
- Add the Tab type to
src/lib/types.ts
:
export type Tab = chrome.tabs.Tab;
- 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:
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.
- Let’s update our background script to handle these actions:
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.
- 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:
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);
};
- Since
getUserActions
is an async function, we need to update the Command Palette component to handle the promise:
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.
- First update our
UserAction
type to include akeybinding
property:
export type UserAction = {
title: string;
id: string;
handler: () => void;
visible: boolean;
keybinding?: string[]; //optional
};
- 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 theuser.actions.ts
file:
interface ProtocolMap {
//...existing protocol map
getPlatformInfo(): Promise<chrome.runtime.PlatformInfo>;
}
export default defineBackground(() => {
//...existing listeners
onMessage("getPlatformInfo", async () => {
return browser.runtime.getPlatformInfo();
});
});
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;
};
- Finally, render the keybindings in the Command Palette component:
{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.
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.
- We need to add the
storage
permission in ourwxt.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"],
},
});
- We will add a new file
src/lib/storage.ts
to handle storage operations. We will use the defined storage from WXT:
// 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.
- We will add a switch (from Shadcn) in our
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;
- 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
:
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:
{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:
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.
- 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 theExtension Storage
section on the left, which will has different storage areas such asLocal
,Session
, andSync
. Our stored data will be visible in theLocal
section.
- In Firefox, open the DevTools by going to
about:debugging#/runtime/this-firefox
and clicking onInspect
for your extension. UnderStorage
tab, and you will see theExtension Storage
section on the left, which has the stored data.
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):
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"],
},
});
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
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"],
},
]
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:
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:
export type Permissions = chrome.permissions.Permissions;
interface ProtocolMap {
//...existing protocol map
getPermissions(): Promise<Permissions>;
}
onMessage("getPermissions", async () => {
return browser.permissions.getAll();
});
Consider the scenarios under which we want the ‘Add Bookmark’ and ‘Remove Bookmark’ to be visible:
Has permission | Is Bookmarked | Show ‘Add’ action | Show ‘Remove’ action |
---|---|---|---|
No | No | No | No |
No | Yes | No | No |
Yes | No | Yes | No |
Yes | Yes | No | Yes |
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:
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:
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:
const requestBookmarksPermission = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (!hasBookmarkPermission) {
const granted = await browser.permissions.request({
permissions: ["bookmarks"],
});
if (granted) {
setHasBookmarkPermission(true);
}
}
};
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
:
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:
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:
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:
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.
Conclusion
Through this series, we have covered the fundamentals of building modern web extensions using WXT. To recall, we have covered the following topics:
- Fundamentals of Web Extensions, the current state of extension development, and the need for a modern approach.
- Setting up a new project with WXT, Tailwind CSS, and Shadcn.
- Working with Content Scripts, building isolated UIs, and fixing some common issues with UI.
- Background Scripts, built in messaging APIs, and using an external wrapper for messaging.
- 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:
- 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.
- 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.
- Testing: WXT has support for Vitest for unit testing and suggests using Playwright for end-to-end testing.
- 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]