diff --git a/README.md b/README.md
index e591bbc..12ccf1a 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ pnpm add @addon-core/browser
- [extension](docs/extension.md)
- [history](docs/history.md)
- [i18n](docs/i18n.md)
+- [identity](docs/identity.md) — Chrome full support; Firefox, Edge, and Opera support the portable web auth flow; Safari is not supported.
- [idle](docs/idle.md)
- [management](docs/management.md)
- [notifications](docs/notifications.md)
@@ -77,49 +78,67 @@ pnpm add @addon-core/browser
## Usage examples
-- setActionPopup (works in MV2 & MV3):
+### Action API across MV2 and MV3
```ts
-import { setActionPopup } from "@addon-core/browser";
+import {setActionPopup, setBadgeText} from "@addon-core/browser";
await setActionPopup("popup.html");
-// Optional per-tab usage (when you have a tab id):
-// await setActionPopup("popup.html", someTabId);
+await setBadgeText("ON");
```
-- getCurrentTab:
+`setActionPopup()` works with `chrome.action` in Manifest V3 and `chrome.browserAction` in Manifest V2.
+
+### Tabs and events
```ts
-import { getCurrentTab } from "@addon-core/browser";
+import {getActiveTab, onTabUpdated} from "@addon-core/browser";
+
+const tab = await getActiveTab();
-const tab = await getCurrentTab();
-if (tab?.id) {
- console.log("Current tab id:", tab.id);
-}
+const off = onTabUpdated((tabId, changeInfo) => {
+ if (tabId === tab.id && changeInfo.status === "complete") {
+ off();
+ }
+});
```
-- onTabUpdated (with unsubscribe):
+Event helpers return an unsubscribe function, making listener lifecycles easy to control.
+
+### Context menus and tab messaging
```ts
-import { onTabUpdated } from "@addon-core/browser";
+import {createOrUpdateContextMenu, onContextMenusClicked, sendTabMessage} from "@addon-core/browser";
-const off = onTabUpdated((tabId, changeInfo, tab) => {
- if (changeInfo.status === "complete") {
- console.log("Tab finished loading:", tabId, tab.url);
- }
+await createOrUpdateContextMenu("save-selection", {
+ title: "Save selection",
+ contexts: ["selection"],
});
-// Later, to stop listening:
-off();
+const off = onContextMenusClicked(async (info, tab) => {
+ if (info.menuItemId !== "save-selection" || !tab?.id) {
+ return;
+ }
+
+ await sendTabMessage(tab.id, {
+ type: "save-selection",
+ text: info.selectionText,
+ });
+});
```
+`createOrUpdateContextMenu()` avoids duplicate-menu errors during extension reloads, and `sendTabMessage()` keeps the background-to-content-script path promise-based.
+
+## Helpers
+
+- [browserDetection](docs/browserDetection.md) — Best-effort browser detection with `BrowserName`, `BrowserFamily`, `guessBrowser()`, `isBrowser()`, and `isBrowserFamily()`.
+
## Utilities
In addition to Chrome API wrappers, this package provides a set of low-level utilities for error handling, promise management, and listener safety. While these are primarily used internally, they are also exported via the `@addon-core/browser/utils` subpath for advanced usage.
For a complete list of utility functions and examples, see the [Utilities Documentation](docs/utils.md).
-
## Not yet covered
These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet (Chrome OS–only APIs are intentionally omitted). If you’d like to contribute, please see [CONTRIBUTING.md](CONTRIBUTING.md) and open an issue/PR.
@@ -132,8 +151,6 @@ These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet
- devtools.* (inspectedWindow, network, panels)
- dns
- fontSettings
-- identity
-- identityProvider
- omnibox
- pageCapture
- platformKeys
@@ -148,4 +165,4 @@ These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet
- tabGroups
- topSites
- tts
-- ttsEngine
\ No newline at end of file
+- ttsEngine
diff --git a/docs/browserDetection.md b/docs/browserDetection.md
new file mode 100644
index 0000000..9a9d7a3
--- /dev/null
+++ b/docs/browserDetection.md
@@ -0,0 +1,114 @@
+# browserDetection
+
+Best-effort browser detection for extension contexts.
+
+Chrome does not expose a native `chrome.runtime.getBrowserInfo()` API. This helper combines available browser signals and returns where the guess came from, so callers can decide how much they trust the result.
+
+## Methods
+
+- [guessBrowser()](#guessBrowser)
+- [isBrowser(guess, ...names)](#isBrowser)
+- [isBrowserFamily(guess, family)](#isBrowserFamily)
+
+## Enums
+
+- `BrowserName`
+- `BrowserFamily`
+- `BrowserGuessSource`
+
+---
+
+
+
+### guessBrowser
+
+```ts
+guessBrowser(): Promise
+```
+
+Guesses the current browser using the best signal available in the current context.
+
+```ts
+import {BrowserName, guessBrowser, isBrowser} from "@addon-core/browser";
+
+const guess = await guessBrowser();
+
+if (isBrowser(guess, BrowserName.Firefox)) {
+ // Firefox-specific path
+}
+```
+
+The result contains the guessed browser name, browser family, source, and optional raw metadata:
+
+```ts
+interface BrowserGuess {
+ name: BrowserName;
+ family: BrowserFamily;
+ source: BrowserGuessSource;
+ rawName?: string;
+ vendor?: string;
+ version?: string;
+}
+```
+
+Detection order:
+
+1. Firefox `runtime.getBrowserInfo()`.
+2. Chromium User-Agent Client Hints via `navigator.userAgentData`.
+3. Brave-specific `navigator.brave`.
+4. Browser-specific globals such as Opera or Safari globals.
+5. Generic Chromium User-Agent Client Hints.
+6. `navigator.userAgent`.
+7. Extension URL protocol from `runtime.getURL("")`.
+
+When no signal is available, the helper returns:
+
+```ts
+{
+ name: BrowserName.Unknown,
+ family: BrowserFamily.Unknown,
+ source: BrowserGuessSource.Unknown
+}
+```
+
+For capability checks, prefer feature detection. Use `guessBrowser()` when product logic, diagnostics, or analytics truly need a browser label.
+
+
+
+### isBrowser
+
+```ts
+isBrowser(guess: BrowserGuess, ...names: BrowserName[]): boolean
+```
+
+Checks whether a browser guess matches one of the provided names.
+
+```ts
+import {BrowserName, guessBrowser, isBrowser} from "@addon-core/browser";
+
+const guess = await guessBrowser();
+
+if (isBrowser(guess, BrowserName.Edge, BrowserName.Chrome)) {
+ // Chromium browser branch for Chrome or Edge
+}
+```
+
+
+
+### isBrowserFamily
+
+```ts
+isBrowserFamily(guess: BrowserGuess, family: BrowserFamily): boolean
+```
+
+Checks whether a browser guess belongs to a browser family.
+
+```ts
+import {BrowserFamily, guessBrowser, isBrowserFamily} from "@addon-core/browser";
+
+const guess = await guessBrowser();
+
+if (isBrowserFamily(guess, BrowserFamily.Chromium)) {
+ // Chromium-family branch
+}
+```
diff --git a/docs/identity.md b/docs/identity.md
new file mode 100644
index 0000000..6284b90
--- /dev/null
+++ b/docs/identity.md
@@ -0,0 +1,170 @@
+# identity
+
+Documentation:
+
+- [Chrome Identity API](https://developer.chrome.com/docs/extensions/reference/api/identity)
+- [Firefox identity API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/identity)
+- [Microsoft Edge extension API support](https://learn.microsoft.com/en-us/microsoft-edge/extensions/developer-guide/api-support)
+
+A promise-based wrapper for the WebExtension `identity` API. Chrome exposes the full API surface. Firefox, Edge, and Opera should use the portable OAuth flow with `getIdentityRedirectUrl()` and `launchWebAuthFlow()`. Safari does not support this API.
+
+## Browser support notes
+
+- Chrome: supports the full `chrome.identity` API, except the underlying `getAccounts()` API is Chrome Dev channel only. This wrapper uses the callback form for Chrome-style runtimes because it works across Manifest V2 and Manifest V3.
+- Firefox: supports the portable `browser.identity.launchWebAuthFlow()` promise API and `getRedirectURL()`.
+- Edge: use `launchWebAuthFlow()` for portable OAuth. `getAuthToken()` and the underlying `getAccounts()` API are officially unsupported even if feature detection sees the methods.
+- Opera: supports the portable web auth flow in Chromium-based builds.
+- Safari: Identity API is not supported.
+
+## Manifest
+
+For Chrome OAuth token helpers, add `identity` and the `oauth2` manifest section:
+
+```json
+{
+ "permissions": ["identity"],
+ "oauth2": {
+ "client_id": "client-id.apps.googleusercontent.com",
+ "scopes": ["profile", "email"]
+ }
+}
+```
+
+For profile email data, add `identity.email`:
+
+```json
+{
+ "permissions": ["identity", "identity.email"]
+}
+```
+
+Interactive authorization flows should be started from a user action, such as a button click.
+
+## Methods
+
+- [getIdentityRedirectUrl(path?)](#getIdentityRedirectUrl)
+- [launchWebAuthFlow(details)](#launchWebAuthFlow)
+- [getAuthToken(details?)](#getAuthToken)
+- [removeCachedAuthToken(details)](#removeCachedAuthToken)
+- [clearAllCachedAuthTokens()](#clearAllCachedAuthTokens)
+- [getProfileUserInfo(details?)](#getProfileUserInfo)
+- [getIdentityAccounts()](#getIdentityAccounts)
+
+## Events
+
+- [onIdentitySignInChanged(callback)](#onIdentitySignInChanged)
+
+---
+
+
+
+### getIdentityRedirectUrl
+
+```
+getIdentityRedirectUrl(path?: string): string
+```
+
+Generates the extension redirect URL for an OAuth flow.
+
+```ts
+import {getIdentityRedirectUrl} from "@addon-core/browser";
+
+const redirectUrl = getIdentityRedirectUrl("oauth");
+```
+
+In Chromium browsers this usually returns a URL like `https://.chromiumapp.org/oauth`.
+
+
+
+### launchWebAuthFlow
+
+```
+launchWebAuthFlow(details: LaunchWebAuthFlowDetails): Promise
+```
+
+Starts a browser-managed OAuth flow and resolves with the final redirect URL. This is the recommended portable API for Firefox, Edge, Opera, and non-Google providers.
+
+```ts
+import {getIdentityRedirectUrl, launchWebAuthFlow} from "@addon-core/browser";
+
+const redirectUrl = getIdentityRedirectUrl("oauth");
+const url = new URL("https://accounts.example.com/oauth/authorize");
+url.searchParams.set("redirect_uri", redirectUrl);
+
+const responseUrl = await launchWebAuthFlow({
+ interactive: true,
+ url: url.toString(),
+});
+```
+
+This wrapper uses the callback form in Chrome-style runtimes to avoid callbackless Manifest V2 flows hanging, and the Promise form in Firefox where callbacks are not accepted.
+
+`LaunchWebAuthFlowDetails` also accepts `redirect_uri` for Firefox. This option is Firefox-only, supported since Firefox 63; loopback redirect URIs are supported since Firefox 86.
+
+
+
+### getAuthToken
+
+```
+getAuthToken(details?: chrome.identity.TokenDetails): Promise
+```
+
+Gets a Chrome OAuth2 access token using the manifest `oauth2` configuration or the provided scopes. The wrapper always resolves to `{ token, grantedScopes }`, including callback-based Chrome runtimes that return those values as separate callback arguments.
+
+```ts
+import {getAuthToken} from "@addon-core/browser";
+
+const {token} = await getAuthToken({interactive: true});
+```
+
+This method is Chrome-focused. In Edge, it is officially unsupported even if present.
+
+
+
+### removeCachedAuthToken
+
+```
+removeCachedAuthToken(details: chrome.identity.InvalidTokenDetails): Promise
+```
+
+Removes an OAuth2 access token from Chrome's token cache.
+
+
+
+### clearAllCachedAuthTokens
+
+```
+clearAllCachedAuthTokens(): Promise
+```
+
+Clears all cached auth tokens and authorization state managed by the Identity API.
+
+
+
+### getProfileUserInfo
+
+```
+getProfileUserInfo(details?: chrome.identity.ProfileDetails): Promise
+```
+
+Returns profile email and ID information. Requires the `identity.email` permission; otherwise Chrome returns empty fields.
+
+
+
+### getIdentityAccounts
+
+```
+getIdentityAccounts(): Promise
+```
+
+Returns accounts present in the Chrome profile. This API is Chrome Dev channel only and should not be used for stable product logic.
+
+
+
+### onIdentitySignInChanged
+
+```
+onIdentitySignInChanged(callback: (account: chrome.identity.AccountInfo, signedIn: boolean) => void): () => void
+```
+
+Fires when the sign-in state changes for an account in the user's profile. Returns an unsubscribe function.
diff --git a/src/browserDetection.test.ts b/src/browserDetection.test.ts
new file mode 100644
index 0000000..0e5b7d5
--- /dev/null
+++ b/src/browserDetection.test.ts
@@ -0,0 +1,179 @@
+import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals";
+import {
+ BrowserFamily,
+ BrowserGuessSource,
+ BrowserName,
+ guessBrowser,
+ isBrowser,
+ isBrowserFamily,
+} from "./browserDetection";
+
+describe("browser detection", () => {
+ let originalBrowser: any;
+ let originalChrome: any;
+ let originalNavigatorDescriptor: PropertyDescriptor | undefined;
+ let originalOprDescriptor: PropertyDescriptor | undefined;
+ let originalSafariDescriptor: PropertyDescriptor | undefined;
+
+ beforeEach(() => {
+ originalBrowser = globalThis.browser;
+ originalChrome = globalThis.chrome;
+ originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
+ originalOprDescriptor = Object.getOwnPropertyDescriptor(globalThis, "opr");
+ originalSafariDescriptor = Object.getOwnPropertyDescriptor(globalThis, "safari");
+
+ delete (globalThis as any).browser;
+ delete (globalThis as any).chrome;
+ delete (globalThis as any).navigator;
+ delete (globalThis as any).opr;
+ delete (globalThis as any).safari;
+ });
+
+ afterEach(() => {
+ (globalThis as any).browser = originalBrowser;
+ globalThis.chrome = originalChrome;
+ restoreGlobalProperty("navigator", originalNavigatorDescriptor);
+ restoreGlobalProperty("opr", originalOprDescriptor);
+ restoreGlobalProperty("safari", originalSafariDescriptor);
+ jest.resetAllMocks();
+ });
+
+ const setGlobalProperty = (name: string, value: any): void => {
+ Object.defineProperty(globalThis, name, {
+ configurable: true,
+ value,
+ writable: true,
+ });
+ };
+
+ const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => {
+ if (descriptor) {
+ Object.defineProperty(globalThis, name, descriptor);
+
+ return;
+ }
+
+ delete (globalThis as any)[name];
+ };
+
+ test("guesses Firefox using runtime.getBrowserInfo", async () => {
+ const getBrowserInfo = jest.fn(() =>
+ Promise.resolve({buildID: "20260708000000", name: "Firefox", vendor: "Mozilla", version: "126.0"})
+ );
+ (globalThis as any).browser = {
+ runtime: {
+ getBrowserInfo,
+ id: "firefox-extension-id",
+ },
+ };
+
+ await expect(guessBrowser()).resolves.toEqual({
+ family: BrowserFamily.Firefox,
+ name: BrowserName.Firefox,
+ rawName: "Firefox",
+ source: BrowserGuessSource.RuntimeBrowserInfo,
+ vendor: "Mozilla",
+ version: "126.0",
+ });
+ expect(getBrowserInfo).toHaveBeenCalledTimes(1);
+ });
+
+ test("guesses Edge using userAgentData fullVersionList", async () => {
+ const getHighEntropyValues = jest.fn((_hints: string[]) =>
+ Promise.resolve({
+ fullVersionList: [
+ {brand: "Chromium", version: "126.0.0.0"},
+ {brand: "Microsoft Edge", version: "126.0.2592.87"},
+ ],
+ })
+ );
+ setGlobalProperty("navigator", {
+ userAgentData: {
+ brands: [
+ {brand: "Chromium", version: "126"},
+ {brand: "Microsoft Edge", version: "126"},
+ ],
+ getHighEntropyValues,
+ },
+ });
+
+ await expect(guessBrowser()).resolves.toEqual({
+ family: BrowserFamily.Chromium,
+ name: BrowserName.Edge,
+ rawName: "Microsoft Edge",
+ source: BrowserGuessSource.UserAgentData,
+ version: "126.0.2592.87",
+ });
+ expect(getHighEntropyValues).toHaveBeenCalledWith(["fullVersionList"]);
+ });
+
+ test("guesses Brave before generic Chromium brands", async () => {
+ const isBrave = jest.fn(() => Promise.resolve(true));
+ setGlobalProperty("navigator", {
+ brave: {isBrave},
+ userAgentData: {
+ brands: [{brand: "Chromium", version: "126"}],
+ },
+ });
+
+ await expect(guessBrowser()).resolves.toEqual({
+ family: BrowserFamily.Chromium,
+ name: BrowserName.Brave,
+ source: BrowserGuessSource.NavigatorBrave,
+ });
+ expect(isBrave).toHaveBeenCalledTimes(1);
+ });
+
+ test("guesses Edge using navigator.userAgent fallback", async () => {
+ setGlobalProperty("navigator", {
+ userAgent:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87",
+ });
+
+ await expect(guessBrowser()).resolves.toMatchObject({
+ family: BrowserFamily.Chromium,
+ name: BrowserName.Edge,
+ source: BrowserGuessSource.UserAgent,
+ version: "126.0.2592.87",
+ });
+ });
+
+ test("falls back to Chromium for chrome-extension urls", async () => {
+ const getURL = jest.fn((_path: string) => "chrome-extension://extension-id/");
+ globalThis.chrome = {
+ runtime: {
+ getURL,
+ id: "extension-id",
+ },
+ } as any;
+
+ await expect(guessBrowser()).resolves.toEqual({
+ family: BrowserFamily.Chromium,
+ name: BrowserName.Chromium,
+ source: BrowserGuessSource.ExtensionUrl,
+ });
+ expect(getURL).toHaveBeenCalledWith("");
+ });
+
+ test("returns unknown when no browser signals are available", async () => {
+ await expect(guessBrowser()).resolves.toEqual({
+ family: BrowserFamily.Unknown,
+ name: BrowserName.Unknown,
+ source: BrowserGuessSource.Unknown,
+ });
+ });
+
+ test("checks browser names and families", () => {
+ const guess = {
+ family: BrowserFamily.Chromium,
+ name: BrowserName.Edge,
+ source: BrowserGuessSource.UserAgent,
+ };
+
+ expect(isBrowser(guess, BrowserName.Edge)).toBe(true);
+ expect(isBrowser(guess, BrowserName.Chrome, BrowserName.Edge)).toBe(true);
+ expect(isBrowser(guess, BrowserName.Firefox)).toBe(false);
+ expect(isBrowserFamily(guess, BrowserFamily.Chromium)).toBe(true);
+ expect(isBrowserFamily(guess, BrowserFamily.Firefox)).toBe(false);
+ });
+});
diff --git a/src/browserDetection.ts b/src/browserDetection.ts
new file mode 100644
index 0000000..c856482
--- /dev/null
+++ b/src/browserDetection.ts
@@ -0,0 +1,345 @@
+import {browser} from "./browser";
+import type {FirefoxRuntime} from "./types";
+
+export enum BrowserName {
+ Arc = "arc",
+ Brave = "brave",
+ Chrome = "chrome",
+ Chromium = "chromium",
+ Edge = "edge",
+ Firefox = "firefox",
+ Opera = "opera",
+ Safari = "safari",
+ Unknown = "unknown",
+ Vivaldi = "vivaldi",
+ Yandex = "yandex",
+}
+
+export enum BrowserFamily {
+ Chromium = "chromium",
+ Firefox = "firefox",
+ Safari = "safari",
+ Unknown = "unknown",
+}
+
+export enum BrowserGuessSource {
+ BrowserGlobal = "browserGlobal",
+ ExtensionUrl = "runtime.getURL",
+ NavigatorBrave = "navigator.brave",
+ RuntimeBrowserInfo = "runtime.getBrowserInfo",
+ Unknown = "unknown",
+ UserAgent = "navigator.userAgent",
+ UserAgentData = "navigator.userAgentData",
+}
+
+export interface BrowserGuess {
+ name: BrowserName;
+ family: BrowserFamily;
+ source: BrowserGuessSource;
+ rawName?: string;
+ vendor?: string;
+ version?: string;
+}
+
+interface UserAgentBrand {
+ brand: string;
+ version: string;
+}
+
+interface NavigatorUADataLike {
+ brands?: UserAgentBrand[];
+ getHighEntropyValues?: (hints: string[]) => Promise<{
+ brands?: UserAgentBrand[];
+ fullVersionList?: UserAgentBrand[];
+ }>;
+}
+
+type NavigatorWithBrowserHints = Navigator & {
+ brave?: {
+ isBrave?: () => boolean | Promise;
+ };
+ userAgentData?: NavigatorUADataLike;
+};
+
+type GlobalBrowserHints = typeof globalThis & {
+ opr?: unknown;
+ safari?: unknown;
+};
+
+const browserFamilies: Record = {
+ [BrowserName.Arc]: BrowserFamily.Chromium,
+ [BrowserName.Brave]: BrowserFamily.Chromium,
+ [BrowserName.Chrome]: BrowserFamily.Chromium,
+ [BrowserName.Chromium]: BrowserFamily.Chromium,
+ [BrowserName.Edge]: BrowserFamily.Chromium,
+ [BrowserName.Firefox]: BrowserFamily.Firefox,
+ [BrowserName.Opera]: BrowserFamily.Chromium,
+ [BrowserName.Safari]: BrowserFamily.Safari,
+ [BrowserName.Unknown]: BrowserFamily.Unknown,
+ [BrowserName.Vivaldi]: BrowserFamily.Chromium,
+ [BrowserName.Yandex]: BrowserFamily.Chromium,
+};
+
+const createBrowserGuess = (
+ name: BrowserName,
+ source: BrowserGuessSource,
+ details: Omit = {}
+): BrowserGuess => ({
+ family: browserFamilies[name],
+ name,
+ source,
+ ...details,
+});
+
+const getNavigator = (): NavigatorWithBrowserHints | undefined => {
+ if (typeof navigator === "undefined") {
+ return undefined;
+ }
+
+ return navigator as NavigatorWithBrowserHints;
+};
+
+const normalizeBrowserName = (name: string): BrowserName | undefined => {
+ const value = name.toLowerCase();
+
+ if (value.includes("microsoft edge") || /\bedg(?:e|a|ios)?\b/.test(value)) {
+ return BrowserName.Edge;
+ }
+
+ if (value.includes("opera") || value.includes("opr")) {
+ return BrowserName.Opera;
+ }
+
+ if (value.includes("brave")) {
+ return BrowserName.Brave;
+ }
+
+ if (value.includes("vivaldi")) {
+ return BrowserName.Vivaldi;
+ }
+
+ if (value.includes("yabrowser") || value.includes("yandex")) {
+ return BrowserName.Yandex;
+ }
+
+ if (value.includes("arc")) {
+ return BrowserName.Arc;
+ }
+
+ if (value.includes("firefox") || value.includes("fennec")) {
+ return BrowserName.Firefox;
+ }
+
+ if (value.includes("safari") && !value.includes("chrome") && !value.includes("chromium")) {
+ return BrowserName.Safari;
+ }
+
+ if (value.includes("google chrome") || value === "chrome") {
+ return BrowserName.Chrome;
+ }
+
+ if (value.includes("chromium")) {
+ return BrowserName.Chromium;
+ }
+};
+
+const guessFromRuntimeBrowserInfo = async (): Promise => {
+ try {
+ const runtime = browser().runtime as unknown as Partial;
+
+ if (typeof runtime.getBrowserInfo !== "function") {
+ return undefined;
+ }
+
+ const info = await runtime.getBrowserInfo();
+ const name = normalizeBrowserName(info.name) ?? BrowserName.Unknown;
+
+ return createBrowserGuess(name, BrowserGuessSource.RuntimeBrowserInfo, {
+ rawName: info.name,
+ vendor: info.vendor,
+ version: info.version,
+ });
+ } catch {
+ return undefined;
+ }
+};
+
+const getUserAgentBrands = async (navigatorApi: NavigatorWithBrowserHints | undefined): Promise => {
+ const userAgentData = navigatorApi?.userAgentData;
+
+ if (!userAgentData) {
+ return [];
+ }
+
+ try {
+ const highEntropyValues = await userAgentData.getHighEntropyValues?.(["fullVersionList"]);
+
+ if (highEntropyValues?.fullVersionList?.length) {
+ return highEntropyValues.fullVersionList;
+ }
+ } catch {
+ // Low entropy brands are still useful when high entropy hints are unavailable.
+ }
+
+ return userAgentData.brands ?? [];
+};
+
+const guessFromBrands = (brands: UserAgentBrand[], includeGenericChromium: boolean): BrowserGuess | undefined => {
+ const priority = [
+ BrowserName.Edge,
+ BrowserName.Opera,
+ BrowserName.Brave,
+ BrowserName.Vivaldi,
+ BrowserName.Yandex,
+ BrowserName.Arc,
+ BrowserName.Chrome,
+ BrowserName.Safari,
+ BrowserName.Firefox,
+ ];
+
+ if (includeGenericChromium) {
+ priority.push(BrowserName.Chromium);
+ }
+
+ for (const name of priority) {
+ const brand = brands.find(item => normalizeBrowserName(item.brand) === name);
+
+ if (brand) {
+ return createBrowserGuess(name, BrowserGuessSource.UserAgentData, {
+ rawName: brand.brand,
+ version: brand.version,
+ });
+ }
+ }
+};
+
+const guessFromBraveNavigator = async (
+ navigatorApi: NavigatorWithBrowserHints | undefined
+): Promise => {
+ const brave = navigatorApi?.brave;
+ const isBrave = brave?.isBrave;
+
+ if (typeof isBrave !== "function") {
+ return undefined;
+ }
+
+ try {
+ if (await isBrave.call(brave)) {
+ return createBrowserGuess(BrowserName.Brave, BrowserGuessSource.NavigatorBrave);
+ }
+ } catch {
+ return undefined;
+ }
+};
+
+const guessFromBrowserGlobals = (): BrowserGuess | undefined => {
+ const globalBrowserHints = globalThis as GlobalBrowserHints;
+
+ if (typeof globalBrowserHints.opr !== "undefined") {
+ return createBrowserGuess(BrowserName.Opera, BrowserGuessSource.BrowserGlobal);
+ }
+
+ if (typeof globalBrowserHints.safari !== "undefined") {
+ return createBrowserGuess(BrowserName.Safari, BrowserGuessSource.BrowserGlobal);
+ }
+};
+
+const guessFromUserAgent = (userAgent: string | undefined): BrowserGuess | undefined => {
+ if (!userAgent) {
+ return undefined;
+ }
+
+ const patterns: Array<[BrowserName, RegExp]> = [
+ [BrowserName.Edge, /\bEdg(?:A|iOS)?\/([\d.]+)/],
+ [BrowserName.Opera, /\b(?:OPR|Opera)\/([\d.]+)/],
+ [BrowserName.Vivaldi, /\bVivaldi\/([\d.]+)/],
+ [BrowserName.Yandex, /\bYaBrowser\/([\d.]+)/],
+ [BrowserName.Arc, /\bArc\/([\d.]+)/],
+ [BrowserName.Firefox, /\b(?:Firefox|FxiOS)\/([\d.]+)/],
+ [BrowserName.Chrome, /\b(?:Chrome|CriOS)\/([\d.]+)/],
+ [BrowserName.Chromium, /\bChromium\/([\d.]+)/],
+ [BrowserName.Safari, /\bVersion\/([\d.]+).*Safari\//],
+ ];
+
+ for (const [name, pattern] of patterns) {
+ const match = userAgent.match(pattern);
+
+ if (match) {
+ return createBrowserGuess(name, BrowserGuessSource.UserAgent, {
+ version: match[1],
+ });
+ }
+ }
+};
+
+const guessFromExtensionUrl = (): BrowserGuess | undefined => {
+ try {
+ const protocol = new URL(browser().runtime.getURL("")).protocol;
+
+ if (protocol === "moz-extension:") {
+ return createBrowserGuess(BrowserName.Firefox, BrowserGuessSource.ExtensionUrl);
+ }
+
+ if (protocol === "safari-extension:" || protocol === "safari-web-extension:") {
+ return createBrowserGuess(BrowserName.Safari, BrowserGuessSource.ExtensionUrl);
+ }
+
+ if (protocol === "chrome-extension:") {
+ return createBrowserGuess(BrowserName.Chromium, BrowserGuessSource.ExtensionUrl);
+ }
+ } catch {
+ return undefined;
+ }
+};
+
+export const guessBrowser = async (): Promise => {
+ const runtimeInfo = await guessFromRuntimeBrowserInfo();
+
+ if (runtimeInfo) {
+ return runtimeInfo;
+ }
+
+ const navigatorApi = getNavigator();
+ const brands = await getUserAgentBrands(navigatorApi);
+ const specificBrand = guessFromBrands(brands, false);
+
+ if (specificBrand) {
+ return specificBrand;
+ }
+
+ const braveInfo = await guessFromBraveNavigator(navigatorApi);
+
+ if (braveInfo) {
+ return braveInfo;
+ }
+
+ const browserGlobalInfo = guessFromBrowserGlobals();
+
+ if (browserGlobalInfo) {
+ return browserGlobalInfo;
+ }
+
+ const genericBrand = guessFromBrands(brands, true);
+
+ if (genericBrand) {
+ return genericBrand;
+ }
+
+ const userAgentInfo = guessFromUserAgent(navigatorApi?.userAgent);
+
+ if (userAgentInfo) {
+ return userAgentInfo;
+ }
+
+ const extensionUrlInfo = guessFromExtensionUrl();
+
+ if (extensionUrlInfo) {
+ return extensionUrlInfo;
+ }
+
+ return createBrowserGuess(BrowserName.Unknown, BrowserGuessSource.Unknown);
+};
+
+export const isBrowser = (guess: BrowserGuess, ...names: BrowserName[]): boolean => names.includes(guess.name);
+
+export const isBrowserFamily = (guess: BrowserGuess, family: BrowserFamily): boolean => guess.family === family;
diff --git a/src/identity.test.ts b/src/identity.test.ts
new file mode 100644
index 0000000..e44481e
--- /dev/null
+++ b/src/identity.test.ts
@@ -0,0 +1,269 @@
+import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals";
+import {
+ clearAllCachedAuthTokens,
+ getAuthToken,
+ getIdentityAccounts,
+ getIdentityRedirectUrl,
+ getProfileUserInfo,
+ launchWebAuthFlow,
+ onIdentitySignInChanged,
+ removeCachedAuthToken,
+} from "./identity";
+
+describe("identity", () => {
+ let originalBrowser: any;
+ let originalChrome: any;
+ let originalNavigatorDescriptor: PropertyDescriptor | undefined;
+
+ beforeEach(() => {
+ originalBrowser = globalThis.browser;
+ originalChrome = globalThis.chrome;
+ originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
+
+ delete (globalThis as any).browser;
+ delete (globalThis as any).chrome;
+ delete (globalThis as any).navigator;
+ });
+
+ afterEach(() => {
+ (globalThis as any).browser = originalBrowser;
+ globalThis.chrome = originalChrome;
+ restoreGlobalProperty("navigator", originalNavigatorDescriptor);
+ jest.resetAllMocks();
+ });
+
+ const setChromeIdentity = (
+ identity: Partial,
+ lastError?: chrome.runtime.LastError,
+ manifestVersion: 2 | 3 = 3
+ ): void => {
+ globalThis.chrome = {
+ identity,
+ runtime: {
+ getManifest: jest.fn(() => ({manifest_version: manifestVersion})),
+ id: "chrome-extension-id",
+ lastError,
+ },
+ } as any;
+ };
+
+ const createEvent = () => {
+ const addListener = jest.fn();
+ const removeListener = jest.fn();
+
+ return {addListener, removeListener};
+ };
+
+ const setNavigator = (navigator: Partial): void => {
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: navigator,
+ writable: true,
+ });
+ };
+
+ const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => {
+ if (descriptor) {
+ Object.defineProperty(globalThis, name, descriptor);
+
+ return;
+ }
+
+ delete (globalThis as any)[name];
+ };
+
+ test("should generate a redirect url", () => {
+ const getRedirectURL = jest.fn((path?: string) => `https://chrome-extension-id.chromiumapp.org/${path}`);
+ setChromeIdentity({getRedirectURL});
+
+ expect(getIdentityRedirectUrl("oauth")).toBe("https://chrome-extension-id.chromiumapp.org/oauth");
+ expect(getRedirectURL).toHaveBeenCalledWith("oauth");
+ });
+
+ test("should launch a Chrome MV2 callback web auth flow", async () => {
+ const launchWebAuthFlowMock = jest.fn(
+ (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) =>
+ cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123")
+ );
+ setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 2);
+
+ await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe(
+ "https://chrome-extension-id.chromiumapp.org/oauth?code=123"
+ );
+ expect(launchWebAuthFlowMock).toHaveBeenCalledWith(
+ {url: "https://accounts.example/oauth", interactive: true},
+ expect.any(Function)
+ );
+ });
+
+ test("should launch a Chrome MV3 callback web auth flow", async () => {
+ const launchWebAuthFlowMock = jest.fn(
+ (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) =>
+ cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123")
+ );
+ setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 3);
+
+ await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe(
+ "https://chrome-extension-id.chromiumapp.org/oauth?code=123"
+ );
+ expect(launchWebAuthFlowMock).toHaveBeenCalledWith(
+ {url: "https://accounts.example/oauth", interactive: true},
+ expect.any(Function)
+ );
+ });
+
+ test("should not use Firefox promise-only flow from a user agent fallback", async () => {
+ const launchWebAuthFlowMock = jest.fn(
+ (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) =>
+ cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123")
+ );
+ setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 3);
+ setNavigator({
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0",
+ });
+
+ await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe(
+ "https://chrome-extension-id.chromiumapp.org/oauth?code=123"
+ );
+ expect(launchWebAuthFlowMock).toHaveBeenCalledWith(
+ {url: "https://accounts.example/oauth", interactive: true},
+ expect.any(Function)
+ );
+ });
+
+ test("should launch a Firefox promise-only web auth flow without callback", async () => {
+ const getBrowserInfo = jest.fn(() =>
+ Promise.resolve({buildID: "1", name: "Firefox", vendor: "Mozilla", version: "86"})
+ );
+ const launchWebAuthFlowMock = jest.fn((_details: any) =>
+ Promise.resolve("https://extension-id.extensions.allizom.org/oauth?code=123")
+ );
+ (globalThis as any).browser = {
+ identity: {
+ launchWebAuthFlow: launchWebAuthFlowMock,
+ },
+ runtime: {
+ getBrowserInfo,
+ getManifest: jest.fn(() => ({manifest_version: 2})),
+ id: "firefox-extension-id",
+ lastError: undefined,
+ },
+ };
+
+ await expect(
+ launchWebAuthFlow({
+ redirect_uri: "https://extension-id.extensions.allizom.org/oauth",
+ url: "https://accounts.example/oauth",
+ })
+ ).resolves.toBe("https://extension-id.extensions.allizom.org/oauth?code=123");
+ expect(launchWebAuthFlowMock).toHaveBeenCalledWith({
+ redirect_uri: "https://extension-id.extensions.allizom.org/oauth",
+ url: "https://accounts.example/oauth",
+ });
+ expect(getBrowserInfo).toHaveBeenCalledTimes(1);
+ });
+
+ test("should reject launchWebAuthFlow when the browser promise rejects", async () => {
+ const errorMessage = "Authorization flow failed";
+ setChromeIdentity({
+ launchWebAuthFlow: jest.fn((_details: chrome.identity.WebAuthFlowDetails) =>
+ Promise.reject(new Error(errorMessage))
+ ),
+ } as any);
+
+ await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).rejects.toThrow(errorMessage);
+ });
+
+ test("should normalize getAuthToken callback token and granted scopes", async () => {
+ const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) =>
+ cb("access-token", ["email", "profile"])
+ );
+ setChromeIdentity({getAuthToken: getAuthTokenMock as any});
+
+ await expect(getAuthToken({interactive: true})).resolves.toEqual({
+ grantedScopes: ["email", "profile"],
+ token: "access-token",
+ });
+ expect(getAuthTokenMock).toHaveBeenCalledWith({interactive: true}, expect.any(Function));
+ });
+
+ test("should keep object-style getAuthToken results", async () => {
+ const result = {grantedScopes: ["email"], token: "access-token"};
+ const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(result));
+ setChromeIdentity({getAuthToken: getAuthTokenMock as any});
+
+ await expect(getAuthToken()).resolves.toBe(result);
+ expect(getAuthTokenMock).toHaveBeenCalledWith({}, expect.any(Function));
+ });
+
+ test("should treat a null getAuthToken callback value as a token result", async () => {
+ const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(null));
+ setChromeIdentity({getAuthToken: getAuthTokenMock as any});
+
+ await expect(getAuthToken()).resolves.toEqual({
+ grantedScopes: undefined,
+ token: null,
+ });
+ });
+
+ test("should reject getAuthToken when runtime lastError is set", async () => {
+ const errorMessage = "OAuth token unavailable";
+ setChromeIdentity(
+ {
+ getAuthToken: jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(undefined)),
+ } as any,
+ {message: errorMessage}
+ );
+
+ await expect(getAuthToken()).rejects.toThrow(errorMessage);
+ });
+
+ test("should remove a cached auth token", async () => {
+ const removeCachedAuthTokenMock = jest.fn((_details: chrome.identity.InvalidTokenDetails, cb: () => void) =>
+ cb()
+ );
+ setChromeIdentity({removeCachedAuthToken: removeCachedAuthTokenMock as any});
+
+ await expect(removeCachedAuthToken({token: "access-token"})).resolves.toBeUndefined();
+ expect(removeCachedAuthTokenMock).toHaveBeenCalledWith({token: "access-token"}, expect.any(Function));
+ });
+
+ test("should clear all cached auth tokens", async () => {
+ const clearAllCachedAuthTokensMock = jest.fn((cb: () => void) => cb());
+ setChromeIdentity({clearAllCachedAuthTokens: clearAllCachedAuthTokensMock as any});
+
+ await expect(clearAllCachedAuthTokens()).resolves.toBeUndefined();
+ expect(clearAllCachedAuthTokensMock).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ test("should get profile user info", async () => {
+ const profile = {email: "user@example.com", id: "gaia-id"};
+ const getProfileUserInfoMock = jest.fn((_details: chrome.identity.ProfileDetails, cb: any) => cb(profile));
+ setChromeIdentity({getProfileUserInfo: getProfileUserInfoMock as any});
+
+ await expect(getProfileUserInfo({accountStatus: "ANY"})).resolves.toBe(profile);
+ expect(getProfileUserInfoMock).toHaveBeenCalledWith({accountStatus: "ANY"}, expect.any(Function));
+ });
+
+ test("should get identity accounts", async () => {
+ const accounts = [{id: "account-id"}];
+ const getAccountsMock = jest.fn((cb: any) => cb(accounts));
+ setChromeIdentity({getAccounts: getAccountsMock as any});
+
+ await expect(getIdentityAccounts()).resolves.toBe(accounts);
+ expect(getAccountsMock).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ test("should subscribe to sign-in changes and unsubscribe", () => {
+ const onSignInChangedEvent = createEvent();
+ setChromeIdentity({onSignInChanged: onSignInChangedEvent as any});
+ const callback = jest.fn();
+
+ const unsubscribe = onIdentitySignInChanged(callback);
+
+ expect(onSignInChangedEvent.addListener).toHaveBeenCalledWith(expect.any(Function));
+ const listener = onSignInChangedEvent.addListener.mock.calls[0][0];
+ unsubscribe();
+ expect(onSignInChangedEvent.removeListener).toHaveBeenCalledWith(listener);
+ });
+});
diff --git a/src/identity.ts b/src/identity.ts
new file mode 100644
index 0000000..cf98f17
--- /dev/null
+++ b/src/identity.ts
@@ -0,0 +1,95 @@
+import {browser} from "./browser";
+import {BrowserGuessSource, BrowserName, guessBrowser, isBrowser} from "./browserDetection";
+import {callWithPromise, checkLastError, handleListener} from "./utils";
+
+type AccountInfo = chrome.identity.AccountInfo;
+type GetAuthTokenResult = chrome.identity.GetAuthTokenResult;
+type InvalidTokenDetails = chrome.identity.InvalidTokenDetails;
+type ProfileDetails = chrome.identity.ProfileDetails;
+type ProfileUserInfo = chrome.identity.ProfileUserInfo;
+type TokenDetails = chrome.identity.TokenDetails;
+
+export interface LaunchWebAuthFlowDetails extends chrome.identity.WebAuthFlowDetails {
+ /**
+ * Firefox-only redirect URI override. Supported in Firefox 63+; loopback redirect URIs are supported in Firefox 86+.
+ */
+ redirect_uri?: string;
+}
+
+type IdentityApi = typeof chrome.identity;
+type GetAuthTokenCallback = (token?: string | GetAuthTokenResult | null, grantedScopes?: string[]) => void;
+
+const identity = (): IdentityApi => browser().identity;
+
+// Methods
+export const getIdentityRedirectUrl = (path?: string): string => identity().getRedirectURL(path);
+
+export const launchWebAuthFlow = async (details: LaunchWebAuthFlowDetails): Promise => {
+ const browserGuess = await guessBrowser();
+ const isFirefoxRuntime =
+ isBrowser(browserGuess, BrowserName.Firefox) && browserGuess.source === BrowserGuessSource.RuntimeBrowserInfo;
+
+ return callWithPromise(cb => {
+ if (isFirefoxRuntime) {
+ return identity().launchWebAuthFlow(details);
+ }
+
+ return identity().launchWebAuthFlow(details, cb);
+ });
+};
+
+export const getAuthToken = (details?: TokenDetails): Promise => {
+ return new Promise((resolve, reject) => {
+ const getToken = identity().getAuthToken as unknown as (
+ details: TokenDetails,
+ callback: GetAuthTokenCallback
+ ) => undefined | Promise;
+
+ const callback: GetAuthTokenCallback = (token, grantedScopes) => {
+ try {
+ checkLastError();
+
+ if (token !== null && typeof token === "object") {
+ resolve(token);
+
+ return;
+ }
+
+ resolve({token, grantedScopes} as GetAuthTokenResult);
+ } catch (e) {
+ reject(e);
+ }
+ };
+
+ try {
+ const result = getToken(details || {}, callback);
+
+ if (result && typeof result.then === "function") {
+ result.then(resolve, reject);
+ }
+ } catch (e) {
+ reject(e);
+ }
+ });
+};
+
+export const removeCachedAuthToken = (details: InvalidTokenDetails): Promise =>
+ callWithPromise(cb => identity().removeCachedAuthToken(details, cb));
+
+export const clearAllCachedAuthTokens = (): Promise =>
+ callWithPromise(cb => identity().clearAllCachedAuthTokens(cb));
+
+export const getProfileUserInfo = (details?: ProfileDetails): Promise =>
+ callWithPromise(cb => identity().getProfileUserInfo(details || {}, cb));
+
+/**
+ * Chrome Dev channel only. Do not build stable product logic on this API.
+ */
+export const getIdentityAccounts = (): Promise => callWithPromise(cb => identity().getAccounts(cb));
+
+// Events
+export const onIdentitySignInChanged = (
+ callback: Parameters[0]
+): (() => void) => {
+ return handleListener(identity().onSignInChanged, callback);
+};
diff --git a/src/index.ts b/src/index.ts
index 3b5a4b4..83e5d99 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@ export * from "./action";
export * from "./alarms";
export * from "./audio";
export * from "./browser";
+export * from "./browserDetection";
export * from "./browsingData";
export * from "./commands";
export * from "./contextMenus";
@@ -12,6 +13,7 @@ export * from "./env";
export * from "./extension";
export * from "./history";
export * from "./i18n";
+export * from "./identity";
export * from "./idle";
export * from "./management";
export * from "./notifications";