feat(install-tab): Managed install check for displaying getting started tab

* Propose new managed install check.

* Refactors.

* Inverted the logic to be positive.

* Added Development to allowList, with override.
This commit is contained in:
Todd Martin 2026-05-29 15:52:56 -04:00 committed by GitHub
parent 7b47aeb974
commit 5e09251cee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 239 additions and 4 deletions

View File

@ -12,7 +12,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/messaging";
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
@ -494,9 +493,7 @@ export default class RuntimeBackground {
this.onInstalledReason === "install" &&
!(await firstValueFrom(this.browserInitialInstallService.extensionInstalled$))
) {
if (!devFlagEnabled("skipWelcomeOnInstall")) {
void BrowserApi.createNewTab("https://bitwarden.com/browser-start/");
}
await this.browserInitialInstallService.displayWelcomePage();
await this.autofillSettingsService.setInlineMenuVisibility(
AutofillOverlayVisibility.OnFieldFocus,

View File

@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended";
import { LogService } from "@bitwarden/logging";
import { BrowserApi } from "./browser-api";
import { ExtensionInstallType } from "./extension-install-type";
type ChromeSettingsGet = chrome.types.ChromeSetting<boolean>["get"];
@ -31,6 +32,88 @@ describe("BrowserApi", () => {
});
});
describe("getInstallType", () => {
let originalIsWebExtensionsApi: boolean;
let originalIsChromeApi: boolean;
beforeEach(() => {
originalIsWebExtensionsApi = BrowserApi.isWebExtensionsApi;
originalIsChromeApi = BrowserApi.isChromeApi;
});
afterEach(() => {
BrowserApi.isWebExtensionsApi = originalIsWebExtensionsApi;
BrowserApi.isChromeApi = originalIsChromeApi;
delete (global.chrome as any).management;
delete (global as any).browser;
});
it.each([
["admin", ExtensionInstallType.Admin],
["development", ExtensionInstallType.Development],
["normal", ExtensionInstallType.Normal],
["sideload", ExtensionInstallType.Sideload],
["other", ExtensionInstallType.Other],
])("returns %s when chrome.management.getSelf reports it", async (raw, expected) => {
(global.chrome as any).management = {
getSelf: jest.fn().mockResolvedValue({ installType: raw }),
};
const result = await BrowserApi.getInstallType();
expect(result).toBe(expected);
});
it("prefers browser.management.getSelf when isWebExtensionsApi is true", async () => {
BrowserApi.isWebExtensionsApi = true;
const browserGetSelf = jest.fn().mockResolvedValue({ installType: "admin" });
const chromeGetSelf = jest.fn().mockResolvedValue({ installType: "normal" });
(global as any).browser = { management: { getSelf: browserGetSelf } };
(global.chrome as any).management = { getSelf: chromeGetSelf };
const result = await BrowserApi.getInstallType();
expect(result).toBe(ExtensionInstallType.Admin);
expect(browserGetSelf).toHaveBeenCalled();
expect(chromeGetSelf).not.toHaveBeenCalled();
});
it("returns Unknown when management.getSelf rejects", async () => {
(global.chrome as any).management = {
getSelf: jest.fn().mockRejectedValue(new Error("not available")),
};
const result = await BrowserApi.getInstallType();
expect(result).toBe(ExtensionInstallType.Unknown);
});
it("returns Unknown when the result has no installType", async () => {
(global.chrome as any).management = {
getSelf: jest.fn().mockResolvedValue({}),
};
const result = await BrowserApi.getInstallType();
expect(result).toBe(ExtensionInstallType.Unknown);
});
it("returns Unknown when chrome.management is absent", async () => {
const result = await BrowserApi.getInstallType();
expect(result).toBe(ExtensionInstallType.Unknown);
});
it("returns Unknown when neither chrome nor browser is available", async () => {
BrowserApi.isWebExtensionsApi = false;
BrowserApi.isChromeApi = false;
const result = await BrowserApi.getInstallType();
expect(result).toBe(ExtensionInstallType.Unknown);
});
});
describe("senderIsInternal", () => {
const EXTENSION_ORIGIN = "chrome-extension://id";

View File

@ -12,6 +12,7 @@ import { TabMessage } from "../../types/tab-messages";
import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service";
import { registerContentScriptsPolyfill } from "./browser-api.register-content-scripts-polyfill";
import { ExtensionInstallType } from "./extension-install-type";
export class BrowserApi {
static isWebExtensionsApi: boolean = typeof browser !== "undefined";
@ -33,6 +34,30 @@ export class BrowserApi {
return BrowserApi.manifestVersion === expectedVersion;
}
/**
* Returns how this extension was installed on the current browser. Use
* {@link ExtensionInstallType.Admin} to detect enterprise-policy installs and
* {@link ExtensionInstallType.Sideload} to detect extensions installed by other software
* on the machine.
*
* `management.getSelf()` is the only `chrome.management` method that does
* not require the "management" manifest permission.
*/
static async getInstallType(): Promise<ExtensionInstallType> {
try {
if (BrowserApi.isWebExtensionsApi) {
const info = await browser.management.getSelf();
return (info?.installType as ExtensionInstallType) ?? ExtensionInstallType.Unknown;
} else if (BrowserApi.isChromeApi) {
const info = await chrome.management.getSelf();
return (info?.installType as ExtensionInstallType) ?? ExtensionInstallType.Unknown;
}
} catch {
// management API not available on this browser (e.g. older Safari)
}
return ExtensionInstallType.Unknown;
}
/**
* Returns `true` if the message sender appears to originate from within this extension.
*

View File

@ -0,0 +1,10 @@
export const ExtensionInstallType = Object.freeze({
Admin: "admin",
Development: "development",
Normal: "normal",
Sideload: "sideload",
Other: "other",
Unknown: "unknown",
} as const);
export type ExtensionInstallType = (typeof ExtensionInstallType)[keyof typeof ExtensionInstallType];

View File

@ -0,0 +1,91 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { GlobalState, StateProvider } from "@bitwarden/common/platform/state";
import { BrowserApi } from "../browser/browser-api";
import { ExtensionInstallType } from "../browser/extension-install-type";
import BrowserInitialInstallService from "./browser-initial-install.service";
jest.mock("@bitwarden/common/platform/misc/flags", () => ({
...jest.requireActual("@bitwarden/common/platform/misc/flags"),
devFlagEnabled: jest.fn(),
}));
describe("BrowserInitialInstallService", () => {
let service: BrowserInitialInstallService;
let getInstallTypeSpy: jest.SpyInstance;
let createNewTabSpy: jest.SpyInstance;
const devFlagEnabledMock = devFlagEnabled as jest.Mock;
beforeEach(() => {
const stateProvider = mock<StateProvider>();
const globalState = mock<GlobalState<boolean>>();
Object.defineProperty(globalState, "state$", { value: of(false) });
stateProvider.getGlobal.mockReturnValue(globalState);
getInstallTypeSpy = jest.spyOn(BrowserApi, "getInstallType");
createNewTabSpy = jest.spyOn(BrowserApi, "createNewTab").mockResolvedValue({} as any);
devFlagEnabledMock.mockReturnValue(false);
service = new BrowserInitialInstallService(stateProvider);
});
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
describe("displayWelcomePage", () => {
it.each([
["Normal", ExtensionInstallType.Normal],
["Development", ExtensionInstallType.Development],
["Unknown", ExtensionInstallType.Unknown],
])("opens the welcome page for %s installs", async (_, installType) => {
getInstallTypeSpy.mockResolvedValue(installType);
await service.displayWelcomePage();
expect(createNewTabSpy).toHaveBeenCalledTimes(1);
expect(createNewTabSpy).toHaveBeenCalledWith("https://bitwarden.com/browser-start/");
});
it.each([
["Admin", ExtensionInstallType.Admin],
["Sideload", ExtensionInstallType.Sideload],
["Other", ExtensionInstallType.Other],
])("does not open the welcome page for %s installs", async (_, installType) => {
getInstallTypeSpy.mockResolvedValue(installType);
await service.displayWelcomePage();
expect(createNewTabSpy).not.toHaveBeenCalled();
});
it.each([
["Normal", ExtensionInstallType.Normal],
["Development", ExtensionInstallType.Development],
["Unknown", ExtensionInstallType.Unknown],
])(
"does not open the welcome page for %s installs when the skipWelcomeOnInstall dev flag is on",
async (_, installType) => {
getInstallTypeSpy.mockResolvedValue(installType);
devFlagEnabledMock.mockImplementation((flag) => flag === "skipWelcomeOnInstall");
await service.displayWelcomePage();
expect(createNewTabSpy).not.toHaveBeenCalled();
},
);
it("checks the skipWelcomeOnInstall dev flag", async () => {
getInstallTypeSpy.mockResolvedValue(ExtensionInstallType.Normal);
await service.displayWelcomePage();
expect(devFlagEnabledMock).toHaveBeenCalledWith("skipWelcomeOnInstall");
});
});
});

View File

@ -1,5 +1,6 @@
import { Observable, map } from "rxjs";
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import {
GlobalState,
EXTENSION_INITIAL_INSTALL_DISK,
@ -7,6 +8,11 @@ import {
StateProvider,
} from "@bitwarden/common/platform/state";
import { BrowserApi } from "../browser/browser-api";
import { ExtensionInstallType } from "../browser/extension-install-type";
const WELCOME_PAGE_URL = "https://bitwarden.com/browser-start/";
const EXTENSION_INSTALLED = new KeyDefinition<boolean>(
EXTENSION_INITIAL_INSTALL_DISK,
"extensionInstalled",
@ -28,4 +34,27 @@ export default class BrowserInitialInstallService {
async setExtensionInstalled(value: boolean) {
await this.extensionInstalled.update(() => value);
}
/**
* Display the configured welcome page on initial install, if the
* install type supports it.
*/
async displayWelcomePage() {
// We use the install type here because it is available at install time, versus
// specific MDM-delivered settings, which are eventually consistent on extension load.
const installType = await BrowserApi.getInstallType();
// We only want to show the welcome page for user-initiated installs and not for
// administrative or sideloaded installs. We also enable it for Development installs
// so unpacked builds (including integration test harnesses) still exercise the
// welcome flow, and for Unknown to handle browsers that don't expose the install type.
const isUserInitiatedInstall =
installType === ExtensionInstallType.Normal ||
installType === ExtensionInstallType.Development ||
installType === ExtensionInstallType.Unknown;
if (isUserInitiatedInstall && !devFlagEnabled("skipWelcomeOnInstall")) {
void BrowserApi.createNewTab(WELCOME_PAGE_URL);
}
}
}