diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index f88a09217b0..b2f5d757bb0 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -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, diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts index aae5d4a50bc..778696f06af 100644 --- a/apps/browser/src/platform/browser/browser-api.spec.ts +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -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["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"; diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index fd985ad3809..5da79a0f3e8 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -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 { + 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. * diff --git a/apps/browser/src/platform/browser/extension-install-type.ts b/apps/browser/src/platform/browser/extension-install-type.ts new file mode 100644 index 00000000000..6329a9f832b --- /dev/null +++ b/apps/browser/src/platform/browser/extension-install-type.ts @@ -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]; diff --git a/apps/browser/src/platform/services/browser-initial-install.service.spec.ts b/apps/browser/src/platform/services/browser-initial-install.service.spec.ts new file mode 100644 index 00000000000..baf676886a2 --- /dev/null +++ b/apps/browser/src/platform/services/browser-initial-install.service.spec.ts @@ -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(); + const globalState = mock>(); + 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"); + }); + }); +}); diff --git a/apps/browser/src/platform/services/browser-initial-install.service.ts b/apps/browser/src/platform/services/browser-initial-install.service.ts index 12b2ea95b9c..da9ecc7da07 100644 --- a/apps/browser/src/platform/services/browser-initial-install.service.ts +++ b/apps/browser/src/platform/services/browser-initial-install.service.ts @@ -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( 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); + } + } }