From d1c6b334b1ec547fe1921d07426468f8f4458378 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:27:28 -0400 Subject: [PATCH] feat(DuckDuckGo): [PM-9388] Add new device type for DuckDuckGo browser * Add new device type for DuckDuckGo browser * Added feature support property for sync domains * Added new features * Added isDuckDuckGo() to CLI * Addressed PR feedback. * Renamed new property * Fixed rename that missed CLI. --- .../browser-platform-utils.service.ts | 8 ++ .../services/cli-platform-utils.service.ts | 8 ++ .../electron-platform-utils.service.ts | 8 ++ .../src/app/core/web-file-download.service.ts | 2 +- .../core/web-platform-utils.service.spec.ts | 89 +++++++++++++++++++ .../app/core/web-platform-utils.service.ts | 20 +++++ .../shared/secrets-list.component.ts | 18 ---- .../src/tools/send/add-edit.component.ts | 8 -- libs/common/src/enums/device-type.enum.ts | 2 + .../abstractions/platform-utils.service.ts | 9 ++ libs/common/src/services/api.service.ts | 26 ++---- 11 files changed, 150 insertions(+), 48 deletions(-) diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index 4ae412fbda6..beac7616d8d 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -207,6 +207,14 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return true; } + supportsAutofill(): boolean { + return true; + } + + supportsFileDownloads(): boolean { + return false; + } + abstract showToast( type: "error" | "success" | "warning" | "info", title: string, diff --git a/apps/cli/src/platform/services/cli-platform-utils.service.ts b/apps/cli/src/platform/services/cli-platform-utils.service.ts index 4e00b58607b..7bed495bbf5 100644 --- a/apps/cli/src/platform/services/cli-platform-utils.service.ts +++ b/apps/cli/src/platform/services/cli-platform-utils.service.ts @@ -108,6 +108,14 @@ export class CliPlatformUtilsService implements PlatformUtilsService { return false; } + supportsAutofill(): boolean { + return false; + } + + supportsFileDownloads(): boolean { + return false; + } + showToast( type: "error" | "success" | "warning" | "info", title: string, diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index b7c82f4e5db..43b867b7a68 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -86,6 +86,14 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { return true; } + supportsAutofill(): boolean { + return false; + } + + supportsFileDownloads(): boolean { + return false; + } + showToast( type: "error" | "success" | "warning" | "info", title: string, diff --git a/apps/web/src/app/core/web-file-download.service.ts b/apps/web/src/app/core/web-file-download.service.ts index ad034702a55..3421203737a 100644 --- a/apps/web/src/app/core/web-file-download.service.ts +++ b/apps/web/src/app/core/web-file-download.service.ts @@ -12,7 +12,7 @@ export class WebFileDownloadService implements FileDownloadService { download(request: FileDownloadRequest): void { const builder = new FileDownloadBuilder(request); const a = window.document.createElement("a"); - if (!this.platformUtilsService.isSafari()) { + if (!this.platformUtilsService.supportsFileDownloads()) { a.rel = "noreferrer"; a.target = "_blank"; } diff --git a/apps/web/src/app/core/web-platform-utils.service.spec.ts b/apps/web/src/app/core/web-platform-utils.service.spec.ts index 3b5cb96b718..6dba3fda782 100644 --- a/apps/web/src/app/core/web-platform-utils.service.spec.ts +++ b/apps/web/src/app/core/web-platform-utils.service.spec.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { DeviceType } from "@bitwarden/common/enums"; + import { WebPlatformUtilsService } from "./web-platform-utils.service"; describe("Web Platform Utils Service", () => { @@ -114,4 +116,91 @@ describe("Web Platform Utils Service", () => { expect(result).toBe("2022.10.2"); }); }); + describe("getDevice", () => { + const originalUserAgent = navigator.userAgent; + + const setUserAgent = (userAgent: string) => { + Object.defineProperty(navigator, "userAgent", { + value: userAgent, + configurable: true, + }); + }; + + const setWindowProperties = (props?: Record) => { + if (!props) { + return; + } + Object.keys(props).forEach((key) => { + Object.defineProperty(window, key, { + value: props[key], + configurable: true, + }); + }); + }; + + afterEach(() => { + // Reset to original after each test + setUserAgent(originalUserAgent); + }); + + const testData: { + userAgent: string; + expectedDevice: DeviceType; + windowProps?: Record; + }[] = [ + { + // DuckDuckGo macoOS browser v1.13 + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15 Ddg/18.3.1", + expectedDevice: DeviceType.DuckDuckGoBrowser, + }, + // DuckDuckGo Windows browser v0.109.7, which does not present the Ddg suffix and is therefore detected as Edge + { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0", + expectedDevice: DeviceType.EdgeBrowser, + }, + { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + expectedDevice: DeviceType.ChromeBrowser, + windowProps: { chrome: {} }, // set window.chrome = {} to simulate Chrome + }, + { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0", + expectedDevice: DeviceType.FirefoxBrowser, + }, + { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15", + expectedDevice: DeviceType.SafariBrowser, + }, + { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/120.0.0.0 Chrome/120.0.0.0 Safari/537.36", + expectedDevice: DeviceType.EdgeBrowser, + }, + { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.65 Safari/537.36 OPR/95.0.4635.46", + expectedDevice: DeviceType.OperaBrowser, + }, + { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.57 Safari/537.36 Vivaldi/6.5.3206.48", + expectedDevice: DeviceType.VivaldiBrowser, + }, + ]; + + test.each(testData)( + "returns $expectedDevice for User-Agent: $userAgent", + ({ userAgent, expectedDevice, windowProps }) => { + setUserAgent(userAgent); + setWindowProperties(windowProps); + const result = webPlatformUtilsService.getDevice(); + expect(result).toBe(expectedDevice); + }, + ); + }); }); diff --git a/apps/web/src/app/core/web-platform-utils.service.ts b/apps/web/src/app/core/web-platform-utils.service.ts index 3df2a7d895b..c3d1f5e3c1a 100644 --- a/apps/web/src/app/core/web-platform-utils.service.ts +++ b/apps/web/src/app/core/web-platform-utils.service.ts @@ -34,6 +34,13 @@ export class WebPlatformUtilsService implements PlatformUtilsService { this.browserCache = DeviceType.EdgeBrowser; } else if (navigator.userAgent.indexOf(" Vivaldi/") !== -1) { this.browserCache = DeviceType.VivaldiBrowser; + } else if ( + // We are only detecting DuckDuckGo browser on macOS currently, as + // it is not presenting the Ddg suffix on Windows. DuckDuckGo users + // on Windows will be detected as Edge. + navigator.userAgent.indexOf("Ddg") !== -1 + ) { + this.browserCache = DeviceType.DuckDuckGoBrowser; } else if ( navigator.userAgent.indexOf(" Safari/") !== -1 && navigator.userAgent.indexOf("Chrome") === -1 @@ -83,6 +90,10 @@ export class WebPlatformUtilsService implements PlatformUtilsService { return this.getDevice() === DeviceType.SafariBrowser; } + isWebKit(): boolean { + return true; + } + isMacAppStore(): boolean { return false; } @@ -120,6 +131,15 @@ export class WebPlatformUtilsService implements PlatformUtilsService { return true; } + supportsAutofill(): boolean { + return false; + } + + // Safari support for blob downloads is inconsistent and requires workarounds + supportsFileDownloads(): boolean { + return !(this.getDevice() === DeviceType.SafariBrowser); + } + showToast( type: "error" | "success" | "warning" | "info", title: string, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts index a7ee818a01f..18ac0a80454 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts @@ -180,22 +180,4 @@ export class SecretsListComponent implements OnDestroy { i18nService.t("valueCopied", i18nService.t("uuid")), ); } - - /** - * TODO: Remove in favor of updating `PlatformUtilsService.copyToClipboard` - */ - private static copyToClipboardAsync( - text: Promise, - platformUtilsService: PlatformUtilsService, - ) { - if (platformUtilsService.isSafari()) { - return navigator.clipboard.write([ - new ClipboardItem({ - ["text/plain"]: text, - }), - ]); - } - - return text.then((t) => platformUtilsService.copyToClipboard(t)); - } } diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 0289664c365..221b751528a 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -148,14 +148,6 @@ export class AddEditComponent implements OnInit, OnDestroy { return null; } - get isSafari() { - return this.platformUtilsService.isSafari(); - } - - get isDateTimeLocalSupported(): boolean { - return !(this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari()); - } - async ngOnInit() { this.accountService.activeAccount$ .pipe( diff --git a/libs/common/src/enums/device-type.enum.ts b/libs/common/src/enums/device-type.enum.ts index d5628536ff7..c462081140e 100644 --- a/libs/common/src/enums/device-type.enum.ts +++ b/libs/common/src/enums/device-type.enum.ts @@ -27,6 +27,7 @@ export enum DeviceType { WindowsCLI = 23, MacOsCLI = 24, LinuxCLI = 25, + DuckDuckGoBrowser = 26, } /** @@ -55,6 +56,7 @@ export const DeviceTypeMetadata: Record = { [DeviceType.IEBrowser]: { category: "webVault", platform: "IE" }, [DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" }, [DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" }, + [DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" }, [DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" }, [DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" }, [DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" }, diff --git a/libs/common/src/platform/abstractions/platform-utils.service.ts b/libs/common/src/platform/abstractions/platform-utils.service.ts index fa0fc8f2501..7586da5a564 100644 --- a/libs/common/src/platform/abstractions/platform-utils.service.ts +++ b/libs/common/src/platform/abstractions/platform-utils.service.ts @@ -28,6 +28,15 @@ export abstract class PlatformUtilsService { abstract getApplicationVersionNumber(): Promise; abstract supportsWebAuthn(win: Window): boolean; abstract supportsDuo(): boolean; + /** + * Returns true if the device supports autofill functionality + */ + abstract supportsAutofill(): boolean; + /** + * Returns true if the device supports native file downloads without + * the need for `target="_blank"` + */ + abstract supportsFileDownloads(): boolean; /** * @deprecated use `@bitwarden/components/ToastService.showToast` instead * diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index ca6cd6570a4..62d300fc029 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -97,7 +97,7 @@ import { PaymentResponse } from "../billing/models/response/payment.response"; import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; -import { DeviceType } from "../enums"; +import { ClientType, DeviceType } from "../enums"; import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request"; import { VaultTimeoutSettingsService } from "../key-management/vault-timeout"; @@ -154,8 +154,6 @@ export type HttpOperations = { export class ApiService implements ApiServiceAbstraction { private device: DeviceType; private deviceType: string; - private isWebClient = false; - private isDesktopClient = false; private refreshTokenPromise: Promise | undefined; /** @@ -178,22 +176,6 @@ export class ApiService implements ApiServiceAbstraction { ) { this.device = platformUtilsService.getDevice(); this.deviceType = this.device.toString(); - this.isWebClient = - this.device === DeviceType.IEBrowser || - this.device === DeviceType.ChromeBrowser || - this.device === DeviceType.EdgeBrowser || - this.device === DeviceType.FirefoxBrowser || - this.device === DeviceType.OperaBrowser || - this.device === DeviceType.SafariBrowser || - this.device === DeviceType.UnknownBrowser || - this.device === DeviceType.VivaldiBrowser; - this.isDesktopClient = - this.device === DeviceType.WindowsDesktop || - this.device === DeviceType.MacOsDesktop || - this.device === DeviceType.LinuxDesktop || - this.device === DeviceType.WindowsCLI || - this.device === DeviceType.MacOsCLI || - this.device === DeviceType.LinuxCLI; } // Auth APIs @@ -838,7 +820,9 @@ export class ApiService implements ApiServiceAbstraction { // Sync APIs async getSync(): Promise { - const path = this.isDesktopClient || this.isWebClient ? "/sync?excludeDomains=true" : "/sync"; + const path = !this.platformUtilsService.supportsAutofill() + ? "/sync?excludeDomains=true" + : "/sync"; const r = await this.send("GET", path, null, true, true); return new SyncResponse(r); } @@ -1875,7 +1859,7 @@ export class ApiService implements ApiServiceAbstraction { private async getCredentials(): Promise { const env = await firstValueFrom(this.environmentService.environment$); - if (!this.isWebClient || env.hasBaseUrl()) { + if (this.platformUtilsService.getClientType() !== ClientType.Web || env.hasBaseUrl()) { return "include"; } return undefined;