- @if (showRowCheckBox) {
- |
- @if (!row.isMarkedAsCritical) {
-
- }
- @if (row.isMarkedAsCritical) {
-
- }
- |
- }
- @if (!showRowCheckBox) {
-
- @if (row.isMarkedAsCritical) {
-
- }
- |
- }
+
+
+ |
|
- {{ row.applicationName }}
+
+
+ {{ row.applicationName }}
+
+ @if (row.isMarkedAsCritical) {
+ {{ "criticalBadge" | i18n }}
+ }
+
|
= new Set();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
From 98060d15bc8d0ff452c4d2e416e0e157798a33e5 Mon Sep 17 00:00:00 2001
From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:59:27 -0600
Subject: [PATCH 09/21] Mark `getProfileCreationDate` as deprecated (#18651)
* mark `getProfileCreationDate` as deprecated
* add reference to tech debt ticket
---
libs/angular/src/vault/services/vault-profile.service.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/libs/angular/src/vault/services/vault-profile.service.ts b/libs/angular/src/vault/services/vault-profile.service.ts
index 3a8c9d4ee95..3977b275d02 100644
--- a/libs/angular/src/vault/services/vault-profile.service.ts
+++ b/libs/angular/src/vault/services/vault-profile.service.ts
@@ -21,6 +21,9 @@ export class VaultProfileService {
* Returns the creation date of the profile.
* Note: `Date`s are mutable in JS, creating a new
* instance is important to avoid unwanted changes.
+ *
+ * @deprecated use `creationDate` directly from the `AccountService.activeAccount$` instead,
+ * PM-31409 will replace all usages of this service.
*/
async getProfileCreationDate(userId: string): Promise {
if (this.profileCreatedDate && userId === this.userId) {
From 27c6aa812149652cbed92b80c888b9dad1303c4b Mon Sep 17 00:00:00 2001
From: rr-bw <102181210+rr-bw@users.noreply.github.com>
Date: Mon, 2 Feb 2026 09:06:04 -0800
Subject: [PATCH 10/21] refactor(input-password-flows): [Auth/PM-27086] Use new
KM Data Types in InputPasswordComponent flows - Account Recovery (#18423)
Update Account Recovery flow to use new KM data types from `master-password.types.ts` / `MasterPasswordService`:
- `MasterPasswordAuthenticationData`
- `MasterPasswordUnlockData`
This allows us to move away from the deprecated `makeMasterKey()` method (which takes email as salt) as we seek to eventually separate the email from the salt.
Changes are behind feature flag: `pm-27086-update-authentication-apis-for-input-password`
---
...zation-user-reset-password.service.spec.ts | 188 +++++++++++++++++-
...rganization-user-reset-password.service.ts | 44 ++++
2 files changed, 230 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts
index afc16e72373..69feb2b86bc 100644
--- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts
+++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts
@@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserResetPasswordDetailsResponse,
+ OrganizationUserResetPasswordRequest,
} from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -13,6 +14,15 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models
import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
+import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
+import {
+ MasterKeyWrappedUserKey,
+ MasterPasswordAuthenticationData,
+ MasterPasswordAuthenticationHash,
+ MasterPasswordSalt,
+ MasterPasswordUnlockData,
+} from "@bitwarden/common/key-management/master-password/types/master-password.types";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -21,7 +31,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
-import { KdfType, KeyService } from "@bitwarden/key-management";
+import { DEFAULT_KDF_CONFIG, KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
@@ -39,6 +49,8 @@ describe("OrganizationUserResetPasswordService", () => {
let i18nService: MockProxy;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
+ let masterPasswordService: FakeMasterPasswordService;
+ let configService: MockProxy;
beforeAll(() => {
keyService = mock();
@@ -48,6 +60,8 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService = mock();
i18nService = mock();
accountService = mockAccountServiceWith(mockUserId);
+ masterPasswordService = new FakeMasterPasswordService();
+ configService = mock();
sut = new OrganizationUserResetPasswordService(
keyService,
@@ -57,6 +71,8 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService,
i18nService,
accountService,
+ masterPasswordService,
+ configService,
);
});
@@ -129,13 +145,23 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
- describe("resetMasterPassword", () => {
+ /**
+ * @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are
+ * any imports/properties in the test setup above that are now un-used and can also be removed.
+ */
+ describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
+ const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = false;
+
const mockNewMP = "new-password";
const mockEmail = "test@example.com";
const mockOrgUserId = "test-org-user-id";
const mockOrgId = "test-org-id";
beforeEach(() => {
+ configService.getFeatureFlag.mockResolvedValue(
+ PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
+ );
+
organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
new OrganizationUserResetPasswordDetailsResponse({
kdf: KdfType.PBKDF2_SHA256,
@@ -185,6 +211,164 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
+ describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
+ // Mock sut method parameters
+ const newMasterPassword = "new-master-password";
+ const email = "user@example.com";
+ const orgUserId = "org-user-id";
+ const orgId = "org-id" as OrganizationId;
+
+ // Mock feature flag value
+ const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = true;
+
+ // Mock method data
+ let organizationUserResetPasswordDetailsResponse: OrganizationUserResetPasswordDetailsResponse;
+ let salt: MasterPasswordSalt;
+ let kdfConfig: KdfConfig;
+ let authenticationData: MasterPasswordAuthenticationData;
+ let unlockData: MasterPasswordUnlockData;
+ let userKey: UserKey;
+
+ beforeEach(() => {
+ // Mock feature flag value
+ configService.getFeatureFlag.mockResolvedValue(
+ PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled,
+ );
+
+ // Mock method data
+ kdfConfig = DEFAULT_KDF_CONFIG;
+
+ organizationUserResetPasswordDetailsResponse =
+ new OrganizationUserResetPasswordDetailsResponse({
+ organizationUserId: orgUserId,
+ kdf: kdfConfig.kdfType,
+ kdfIterations: kdfConfig.iterations,
+ resetPasswordKey: "test-reset-password-key",
+ encryptedPrivateKey: "test-encrypted-private-key",
+ });
+
+ organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
+ organizationUserResetPasswordDetailsResponse,
+ );
+
+ const mockDecryptedOrgKeyBytes = new Uint8Array(64).fill(1);
+ const mockDecryptedOrgKey = new SymmetricCryptoKey(mockDecryptedOrgKeyBytes) as OrgKey;
+
+ keyService.orgKeys$.mockReturnValue(
+ of({ [orgId]: mockDecryptedOrgKey } as Record),
+ );
+
+ const mockDecryptedPrivateKeyBytes = new Uint8Array(64).fill(2);
+ encryptService.unwrapDecapsulationKey.mockResolvedValue(mockDecryptedPrivateKeyBytes);
+
+ const mockDecryptedUserKeyBytes = new Uint8Array(64).fill(3);
+ const mockUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes);
+ encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockUserKey); // returns `SymmetricCryptoKey`
+ userKey = mockUserKey as UserKey; // type cast to `UserKey` (see code implementation). Points to same object as mockUserKey.
+
+ salt = email as MasterPasswordSalt;
+ masterPasswordService.mock.emailToSalt.mockReturnValue(salt);
+
+ authenticationData = {
+ salt,
+ kdf: kdfConfig,
+ masterPasswordAuthenticationHash:
+ "masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
+ };
+
+ unlockData = {
+ salt,
+ kdf: kdfConfig,
+ masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
+ } as MasterPasswordUnlockData;
+
+ masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
+ authenticationData,
+ );
+ masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
+ });
+
+ it("should throw an error if the organizationUserResetPasswordDetailsResponse is nullish", async () => {
+ // Arrange
+ organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null);
+
+ // Act
+ const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
+
+ // Assert
+ await expect(promise).rejects.toThrow();
+ });
+
+ it("should throw an error if the org key cannot be found", async () => {
+ // Arrange
+ keyService.orgKeys$.mockReturnValue(of({} as Record));
+
+ // Act
+ const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
+
+ // Assert
+ await expect(promise).rejects.toThrow("No org key found");
+ });
+
+ it("should throw an error if orgKeys$ returns null", async () => {
+ // Arrange
+ keyService.orgKeys$.mockReturnValue(of(null));
+
+ // Act
+ const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
+
+ // Assert
+ await expect(promise).rejects.toThrow();
+ });
+
+ it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
+ // Act
+ await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
+
+ // Assert
+ const request = OrganizationUserResetPasswordRequest.newConstructor(
+ authenticationData,
+ unlockData,
+ );
+
+ expect(masterPasswordService.mock.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
+ newMasterPassword,
+ kdfConfig,
+ salt,
+ );
+
+ expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
+ newMasterPassword,
+ kdfConfig,
+ salt,
+ userKey,
+ );
+
+ expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
+ orgId,
+ orgUserId,
+ request,
+ );
+ });
+
+ it("should call the API method to reset the user's master password", async () => {
+ // Act
+ await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId);
+
+ // Assert
+ const request = OrganizationUserResetPasswordRequest.newConstructor(
+ authenticationData,
+ unlockData,
+ );
+ expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledTimes(1);
+ expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith(
+ orgId,
+ orgUserId,
+ request,
+ );
+ });
+ });
+
describe("getPublicKeys", () => {
it("should return public keys for organizations that have reset password enrolled", async () => {
const result = await sut.getPublicKeys("userId" as UserId);
diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts
index 88797f86650..bd3dd7fbb0b 100644
--- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts
+++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts
@@ -12,11 +12,15 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
+import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -47,6 +51,8 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
private organizationApiService: OrganizationApiServiceAbstraction,
private i18nService: I18nService,
private accountService: AccountService,
+ private masterPasswordService: MasterPasswordServiceAbstraction,
+ private configService: ConfigService,
) {}
/**
@@ -140,6 +146,44 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR
? new PBKDF2KdfConfig(response.kdfIterations)
: new Argon2KdfConfig(response.kdfIterations, response.kdfMemory, response.kdfParallelism);
+ const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
+ FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
+ );
+
+ if (newApisWithInputPasswordFlagEnabled) {
+ const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
+
+ // Create authentication and unlock data
+ const authenticationData =
+ await this.masterPasswordService.makeMasterPasswordAuthenticationData(
+ newMasterPassword,
+ kdfConfig,
+ salt,
+ );
+
+ const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
+ newMasterPassword,
+ kdfConfig,
+ salt,
+ existingUserKey,
+ );
+
+ // Create request
+ const request = OrganizationUserResetPasswordRequest.newConstructor(
+ authenticationData,
+ unlockData,
+ );
+
+ // Change user's password
+ await this.organizationUserApiService.putOrganizationUserResetPassword(
+ orgId,
+ orgUserId,
+ request,
+ );
+
+ return; // EARLY RETURN for flagged code
+ }
+
// Create new master key and hash new password
const newMasterKey = await this.keyService.makeMasterKey(
newMasterPassword,
From 5d17d9ee718aba156b071493e8f57c98eed072cd Mon Sep 17 00:00:00 2001
From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com>
Date: Mon, 2 Feb 2026 09:37:02 -0800
Subject: [PATCH 11/21] Revert "[PM-26703]- Browser - Update autofill Behavior
(#18467)" (#18723)
This reverts commit 7b583aa0ecf865472d67b2a61a56cb064d31a2b7.
---
.../autofill-vault-list-items.component.html | 3 +-
.../autofill-vault-list-items.component.ts | 8 +-
.../item-more-options.component.html | 4 +-
.../item-more-options.component.ts | 18 +++-
.../vault-list-items-container.component.html | 22 +++--
.../vault-list-items-container.component.ts | 87 ++++++++++++++-----
.../settings/appearance-v2.component.html | 8 +-
.../settings/appearance-v2.component.spec.ts | 14 ++-
.../popup/settings/appearance-v2.component.ts | 15 ++++
.../vault-settings/vault-settings.service.ts | 11 +++
.../key-state/vault-settings.state.ts | 9 ++
.../vault-settings/vault-settings.service.ts | 21 ++++-
12 files changed, 181 insertions(+), 39 deletions(-)
diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html
index 0e9a12b504e..47ef0284d6a 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html
@@ -5,7 +5,8 @@
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
- isAutofillList
+ showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
+ [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
[groupByType]="groupByType()"
>
diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts
index adb4e6b6866..64f662ab840 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts
@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
-import { combineLatest, map, Observable } from "rxjs";
+import { combineLatest, map, Observable, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@@ -42,6 +42,12 @@ export class AutofillVaultListItemsComponent {
*/
protected showRefresh: boolean = BrowserPopupUtils.inSidebar(window);
+ /** Flag indicating whether the login item should automatically autofill when clicked */
+ protected clickItemsToAutofillVaultView$: Observable =
+ this.vaultSettingsService.clickItemsToAutofillVaultView$.pipe(
+ startWith(true), // Start with true to avoid flashing the fill button on first load
+ );
+
protected readonly groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
);
diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html
index 223029aed0a..be67869d3df 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html
@@ -8,14 +8,14 @@
>
@if (!decryptionFailure) {
-
+
-
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts
index 7a6c1db8026..d7de51ad20f 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts
@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
-import { booleanAttribute, Component, input, Input } from "@angular/core";
+import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
@@ -76,10 +76,22 @@ export class ItemMoreOptionsComponent {
}
/**
- * Flag to show the autofill menu options. Used for items that are
+ * Flag to show view item menu option. Used when something else is
+ * assigned as the primary action for the item, such as autofill.
+ */
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ @Input({ transform: booleanAttribute })
+ showViewOption = false;
+
+ /**
+ * Flag to hide the autofill menu options. Used for items that are
* already in the autofill list suggestion.
*/
- readonly showAutofill = input(false, { transform: booleanAttribute });
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ @Input({ transform: booleanAttribute })
+ hideAutofillOptions = false;
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html
index d3bc025905e..3dac158b8e1 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html
@@ -90,11 +90,11 @@
-
+
-
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts
index 6e867910d1a..469247f9692 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts
@@ -136,18 +136,24 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
- readonly ciphers = input([]);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ ciphers = input([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
- readonly groupByType = input(false);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ groupByType = input(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
- readonly cipherGroups = computed<
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ cipherGroups = computed<
{
subHeaderKey?: string;
ciphers: PopupCipherViewLike[];
@@ -189,7 +195,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
- readonly title = input(undefined);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ title = input(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -197,20 +205,24 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
- readonly collapsibleKey = input(undefined);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ collapsibleKey = input(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
-
- readonly description = input(undefined);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ description = input(undefined);
/**
* Option to show a refresh button in the section header.
*/
-
- readonly showRefresh = input(false, { transform: booleanAttribute });
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
@@ -223,16 +235,23 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Flag indicating that the current tab location is blocked
*/
- readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
- readonly cipherItemTitleKey = computed(() => {
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
- const key = !this.currentUriIsBlocked() ? "autofillTitle" : "viewItemTitle";
+ const key =
+ this.primaryActionAutofill() && !this.currentURIIsBlocked()
+ ? "autofillTitle"
+ : "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
};
});
@@ -240,25 +259,47 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Option to show the autofill button for each item.
*/
- readonly isAutofillList = input(false, { transform: booleanAttribute });
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ showAutofillButton = input(false, { transform: booleanAttribute });
/**
- * Computed property whether the cipher select action should perform autofill
+ * Flag indicating whether the suggested cipher item autofill button should be shown or not
*/
- readonly shouldAutofillOnSelect = computed(
- () => this.isAutofillList() && !this.currentUriIsBlocked(),
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ hideAutofillButton = computed(
+ () => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
);
+ /**
+ * Flag indicating whether the cipher item autofill menu options should be shown or not
+ */
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
+
+ /**
+ * Option to perform autofill operation as the primary action for autofill suggestions.
+ */
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ primaryActionAutofill = input(false, { transform: booleanAttribute });
+
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
- readonly disableSectionMargin = input(false, { transform: booleanAttribute });
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
- readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
@@ -272,7 +313,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
- protected readonly autofillShortcutTooltip = signal(undefined);
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ protected autofillShortcutTooltip = signal(undefined);
constructor(
private i18nService: I18nService,
@@ -297,8 +340,10 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
}
}
- onCipherSelect(cipher: PopupCipherViewLike) {
- return this.shouldAutofillOnSelect() ? this.doAutofill(cipher) : this.onViewCipher(cipher);
+ primaryActionOnSelect(cipher: PopupCipherViewLike) {
+ return this.primaryActionAutofill() && !this.currentURIIsBlocked()
+ ? this.doAutofill(cipher)
+ : this.onViewCipher(cipher);
}
/**
diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance-v2.component.html
index d53db1d74c2..b58316a8d64 100644
--- a/apps/browser/src/vault/popup/settings/appearance-v2.component.html
+++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.html
@@ -50,10 +50,16 @@
-
+
{{ "showQuickCopyActions" | i18n }}
+
+
+
+ {{ "clickToAutofill" | i18n }}
+
+
diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts
index 40fbc4b7aa6..9e1beab5787 100644
--- a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts
+++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts
@@ -59,12 +59,14 @@ describe("AppearanceV2Component", () => {
const enableRoutingAnimation$ = new BehaviorSubject(true);
const enableCompactMode$ = new BehaviorSubject(false);
const showQuickCopyActions$ = new BehaviorSubject(false);
+ const clickItemsToAutofillVaultView$ = new BehaviorSubject(false);
const setSelectedTheme = jest.fn().mockResolvedValue(undefined);
const setShowFavicons = jest.fn().mockResolvedValue(undefined);
const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined);
const setEnableRoutingAnimation = jest.fn().mockResolvedValue(undefined);
const setEnableCompactMode = jest.fn().mockResolvedValue(undefined);
const setShowQuickCopyActions = jest.fn().mockResolvedValue(undefined);
+ const setClickItemsToAutofillVaultView = jest.fn().mockResolvedValue(undefined);
const mockWidthService: Partial = {
width$: new BehaviorSubject("default"),
@@ -111,7 +113,10 @@ describe("AppearanceV2Component", () => {
},
{
provide: VaultSettingsService,
- useValue: mock(),
+ useValue: {
+ clickItemsToAutofillVaultView$,
+ setClickItemsToAutofillVaultView,
+ },
},
],
})
@@ -142,6 +147,7 @@ describe("AppearanceV2Component", () => {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default",
+ clickItemsToAutofillVaultView: false,
});
});
@@ -187,5 +193,11 @@ describe("AppearanceV2Component", () => {
expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide");
});
+
+ it("updates the click items to autofill vault view setting", () => {
+ component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true);
+
+ expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true);
+ });
});
});
diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts
index 1e0ad517851..e02ccf25f3e 100644
--- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts
+++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts
@@ -66,6 +66,7 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default" as PopupWidthOption,
+ clickItemsToAutofillVaultView: false,
});
/** To avoid flashes of inaccurate values, only show the form after the entire form is populated. */
@@ -111,6 +112,9 @@ export class AppearanceV2Component implements OnInit {
this.copyButtonsService.showQuickCopyActions$,
);
const width = await firstValueFrom(this.popupSizeService.width$);
+ const clickItemsToAutofillVaultView = await firstValueFrom(
+ this.vaultSettingsService.clickItemsToAutofillVaultView$,
+ );
// Set initial values for the form
this.appearanceForm.setValue({
@@ -121,6 +125,7 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode,
showQuickCopyActions,
width,
+ clickItemsToAutofillVaultView,
});
this.formLoading = false;
@@ -166,6 +171,16 @@ export class AppearanceV2Component implements OnInit {
.subscribe((width) => {
void this.updateWidth(width);
});
+
+ this.appearanceForm.controls.clickItemsToAutofillVaultView.valueChanges
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((clickItemsToAutofillVaultView) => {
+ void this.updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
+ });
+ }
+
+ async updateClickItemsToAutofillVaultView(clickItemsToAutofillVaultView: boolean) {
+ await this.vaultSettingsService.setClickItemsToAutofillVaultView(clickItemsToAutofillVaultView);
}
async updateFavicon(enableFavicon: boolean) {
diff --git a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts
index 7a91d97ec7f..01b0011b7f7 100644
--- a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts
+++ b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts
@@ -16,6 +16,11 @@ export abstract class VaultSettingsService {
* An observable monitoring the state of the show identities on the current tab.
*/
abstract showIdentitiesCurrentTab$: Observable;
+ /**
+ * An observable monitoring the state of the click items on the Vault view
+ * for Autofill suggestions.
+ */
+ abstract clickItemsToAutofillVaultView$: Observable;
/**
* Saves the enable passkeys setting to disk.
@@ -32,4 +37,10 @@ export abstract class VaultSettingsService {
* @param value The new value for the show identities on tab page setting.
*/
abstract setShowIdentitiesCurrentTab(value: boolean): Promise;
+ /**
+ * Saves the click items on vault View for Autofill suggestions to disk.
+ * @param value The new value for the click items on vault View for
+ * Autofill suggestions setting.
+ */
+ abstract setClickItemsToAutofillVaultView(value: boolean): Promise;
}
diff --git a/libs/common/src/vault/services/key-state/vault-settings.state.ts b/libs/common/src/vault/services/key-state/vault-settings.state.ts
index 21364bbbf8e..35bb776cc96 100644
--- a/libs/common/src/vault/services/key-state/vault-settings.state.ts
+++ b/libs/common/src/vault/services/key-state/vault-settings.state.ts
@@ -25,3 +25,12 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition(
clearOn: [], // do not clear user settings
},
);
+
+export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition(
+ VAULT_SETTINGS_DISK,
+ "clickItemsToAutofillOnVaultView",
+ {
+ deserializer: (obj) => obj,
+ clearOn: [], // do not clear user settings
+ },
+);
diff --git a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts
index 098ca5faf2c..dbdb3a58dcc 100644
--- a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts
+++ b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts
@@ -1,4 +1,4 @@
-import { Observable, combineLatest, map } from "rxjs";
+import { Observable, combineLatest, map, shareReplay } from "rxjs";
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
@@ -7,6 +7,7 @@ import {
SHOW_CARDS_CURRENT_TAB,
SHOW_IDENTITIES_CURRENT_TAB,
USER_ENABLE_PASSKEYS,
+ CLICK_ITEMS_AUTOFILL_VAULT_VIEW,
} from "../key-state/vault-settings.state";
import { RestrictedItemTypesService } from "../restricted-item-types.service";
@@ -48,6 +49,17 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
readonly showIdentitiesCurrentTab$: Observable =
this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true));
+ private clickItemsToAutofillVaultViewState: ActiveUserState =
+ this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW);
+ /**
+ * {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
+ */
+ readonly clickItemsToAutofillVaultView$: Observable =
+ this.clickItemsToAutofillVaultViewState.state$.pipe(
+ map((x) => x ?? false),
+ shareReplay({ bufferSize: 1, refCount: false }),
+ );
+
constructor(
private stateProvider: StateProvider,
private restrictedItemTypesService: RestrictedItemTypesService,
@@ -67,6 +79,13 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
await this.showIdentitiesCurrentTabState.update(() => value);
}
+ /**
+ * {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView}
+ */
+ async setClickItemsToAutofillVaultView(value: boolean): Promise {
+ await this.clickItemsToAutofillVaultViewState.update(() => value);
+ }
+
/**
* {@link VaultSettingsServiceAbstraction.setEnablePasskeys}
*/
From 7f1c68a24d1a3fa6d230f9400b4d6e8e5638f652 Mon Sep 17 00:00:00 2001
From: Mike Amirault
Date: Mon, 2 Feb 2026 13:10:28 -0500
Subject: [PATCH 12/21] [PM-30675] Fix certain data fields being incorrect in
account-restricted exports (#18531)
---
libs/common/src/models/export/fido2-credential.export.ts | 8 +++++---
libs/common/src/models/export/login.export.ts | 6 +++++-
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/libs/common/src/models/export/fido2-credential.export.ts b/libs/common/src/models/export/fido2-credential.export.ts
index ce9c754fea3..46131a67060 100644
--- a/libs/common/src/models/export/fido2-credential.export.ts
+++ b/libs/common/src/models/export/fido2-credential.export.ts
@@ -75,7 +75,7 @@ export class Fido2CredentialExport {
domain.userDisplayName =
req.userDisplayName != null ? new EncString(req.userDisplayName) : null;
domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null;
- domain.creationDate = req.creationDate;
+ domain.creationDate = req.creationDate != null ? new Date(req.creationDate) : null;
return domain;
}
@@ -111,10 +111,12 @@ export class Fido2CredentialExport {
this.rpId = safeGetString(o.rpId);
this.userHandle = safeGetString(o.userHandle);
this.userName = safeGetString(o.userName);
- this.counter = safeGetString(String(o.counter));
+ this.counter = safeGetString(o instanceof Fido2CredentialView ? String(o.counter) : o.counter);
this.rpName = safeGetString(o.rpName);
this.userDisplayName = safeGetString(o.userDisplayName);
- this.discoverable = safeGetString(String(o.discoverable));
+ this.discoverable = safeGetString(
+ o instanceof Fido2CredentialView ? String(o.discoverable) : o.discoverable,
+ );
this.creationDate = o.creationDate;
}
}
diff --git a/libs/common/src/models/export/login.export.ts b/libs/common/src/models/export/login.export.ts
index b727c614bdf..9d926e5ede5 100644
--- a/libs/common/src/models/export/login.export.ts
+++ b/libs/common/src/models/export/login.export.ts
@@ -39,7 +39,11 @@ export class LoginExport {
domain.username = req.username != null ? new EncString(req.username) : null;
domain.password = req.password != null ? new EncString(req.password) : null;
domain.totp = req.totp != null ? new EncString(req.totp) : null;
- // Fido2credentials are currently not supported for exports.
+ if (req.fido2Credentials != null) {
+ domain.fido2Credentials = req.fido2Credentials.map((f2) =>
+ Fido2CredentialExport.toDomain(f2),
+ );
+ }
return domain;
}
From fd90efabe4e31b169031902f29baf906785b2772 Mon Sep 17 00:00:00 2001
From: Isaiah Inuwa
Date: Mon, 2 Feb 2026 13:13:17 -0600
Subject: [PATCH 13/21] Split NAPI modules [PM-31598] (#18722)
---
.github/CODEOWNERS | 3 +
.../desktop_native/napi/src/autofill.rs | 332 +++++
.../desktop_native/napi/src/autostart.rs | 9 +
.../desktop_native/napi/src/autotype.rs | 20 +
.../desktop_native/napi/src/biometrics.rs | 100 ++
.../desktop_native/napi/src/biometrics_v2.rs | 116 ++
.../napi/src/chromium_importer.rs | 116 ++
.../desktop_native/napi/src/clipboards.rs | 15 +
apps/desktop/desktop_native/napi/src/ipc.rs | 106 ++
apps/desktop/desktop_native/napi/src/lib.rs | 1260 +----------------
.../desktop_native/napi/src/logging.rs | 131 ++
.../napi/src/passkey_authenticator.rs | 9 +
.../desktop_native/napi/src/passwords.rs | 46 +
.../desktop_native/napi/src/powermonitors.rs | 26 +
.../napi/src/processisolations.rs | 23 +
.../desktop_native/napi/src/sshagent.rs | 163 +++
.../napi/src/windows_registry.rs | 16 +
17 files changed, 1250 insertions(+), 1241 deletions(-)
create mode 100644 apps/desktop/desktop_native/napi/src/autofill.rs
create mode 100644 apps/desktop/desktop_native/napi/src/autostart.rs
create mode 100644 apps/desktop/desktop_native/napi/src/autotype.rs
create mode 100644 apps/desktop/desktop_native/napi/src/biometrics.rs
create mode 100644 apps/desktop/desktop_native/napi/src/biometrics_v2.rs
create mode 100644 apps/desktop/desktop_native/napi/src/chromium_importer.rs
create mode 100644 apps/desktop/desktop_native/napi/src/clipboards.rs
create mode 100644 apps/desktop/desktop_native/napi/src/ipc.rs
create mode 100644 apps/desktop/desktop_native/napi/src/logging.rs
create mode 100644 apps/desktop/desktop_native/napi/src/passkey_authenticator.rs
create mode 100644 apps/desktop/desktop_native/napi/src/passwords.rs
create mode 100644 apps/desktop/desktop_native/napi/src/powermonitors.rs
create mode 100644 apps/desktop/desktop_native/napi/src/processisolations.rs
create mode 100644 apps/desktop/desktop_native/napi/src/sshagent.rs
create mode 100644 apps/desktop/desktop_native/napi/src/windows_registry.rs
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b7fb098e662..baec07ca28d 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -154,6 +154,9 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-desktop-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/autotype @bitwarden/team-autofill-desktop-dev
+apps/desktop/desktop_native/napi/src/autofill.rs @bitwarden/team-autofill-desktop-dev
+apps/desktop/desktop_native/napi/src/autotype.rs @bitwarden/team-autofill-desktop-dev
+apps/desktop/desktop_native/napi/src/sshagent.rs @bitwarden/team-autofill-desktop-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-desktop-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-desktop-dev
diff --git a/apps/desktop/desktop_native/napi/src/autofill.rs b/apps/desktop/desktop_native/napi/src/autofill.rs
new file mode 100644
index 00000000000..7717b22ccef
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/autofill.rs
@@ -0,0 +1,332 @@
+#[napi]
+pub mod autofill {
+ use desktop_core::ipc::server::{Message, MessageType};
+ use napi::{
+ bindgen_prelude::FnArgs,
+ threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
+ };
+ use serde::{de::DeserializeOwned, Deserialize, Serialize};
+ use tracing::error;
+
+ #[napi]
+ pub async fn run_command(value: String) -> napi::Result {
+ desktop_core::autofill::run_command(value)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[derive(Debug, serde::Serialize, serde:: Deserialize)]
+ pub enum BitwardenError {
+ Internal(String),
+ }
+
+ #[napi(string_enum)]
+ #[derive(Debug, Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub enum UserVerification {
+ #[napi(value = "preferred")]
+ Preferred,
+ #[napi(value = "required")]
+ Required,
+ #[napi(value = "discouraged")]
+ Discouraged,
+ }
+
+ #[derive(Serialize, Deserialize)]
+ #[serde(bound = "T: Serialize + DeserializeOwned")]
+ pub struct PasskeyMessage {
+ pub sequence_number: u32,
+ pub value: Result,
+ }
+
+ #[napi(object)]
+ #[derive(Debug, Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct Position {
+ pub x: i32,
+ pub y: i32,
+ }
+
+ #[napi(object)]
+ #[derive(Debug, Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct PasskeyRegistrationRequest {
+ pub rp_id: String,
+ pub user_name: String,
+ pub user_handle: Vec,
+ pub client_data_hash: Vec,
+ pub user_verification: UserVerification,
+ pub supported_algorithms: Vec,
+ pub window_xy: Position,
+ pub excluded_credentials: Vec>,
+ }
+
+ #[napi(object)]
+ #[derive(Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct PasskeyRegistrationResponse {
+ pub rp_id: String,
+ pub client_data_hash: Vec,
+ pub credential_id: Vec,
+ pub attestation_object: Vec,
+ }
+
+ #[napi(object)]
+ #[derive(Debug, Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct PasskeyAssertionRequest {
+ pub rp_id: String,
+ pub client_data_hash: Vec,
+ pub user_verification: UserVerification,
+ pub allowed_credentials: Vec>,
+ pub window_xy: Position,
+ //extension_input: Vec, TODO: Implement support for extensions
+ }
+
+ #[napi(object)]
+ #[derive(Debug, Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct PasskeyAssertionWithoutUserInterfaceRequest {
+ pub rp_id: String,
+ pub credential_id: Vec,
+ pub user_name: String,
+ pub user_handle: Vec,
+ pub record_identifier: Option,
+ pub client_data_hash: Vec,
+ pub user_verification: UserVerification,
+ pub window_xy: Position,
+ }
+
+ #[napi(object)]
+ #[derive(Debug, Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct NativeStatus {
+ pub key: String,
+ pub value: String,
+ }
+
+ #[napi(object)]
+ #[derive(Serialize, Deserialize)]
+ #[serde(rename_all = "camelCase")]
+ pub struct PasskeyAssertionResponse {
+ pub rp_id: String,
+ pub user_handle: Vec,
+ pub signature: Vec,
+ pub client_data_hash: Vec,
+ pub authenticator_data: Vec,
+ pub credential_id: Vec,
+ }
+
+ #[napi]
+ pub struct AutofillIpcServer {
+ server: desktop_core::ipc::server::Server,
+ }
+
+ // FIXME: Remove unwraps! They panic and terminate the whole application.
+ #[allow(clippy::unwrap_used)]
+ #[napi]
+ impl AutofillIpcServer {
+ /// Create and start the IPC server without blocking.
+ ///
+ /// @param name The endpoint name to listen on. This name uniquely identifies the IPC
+ /// connection and must be the same for both the server and client. @param callback
+ /// This function will be called whenever a message is received from a client.
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi(factory)]
+ pub async fn listen(
+ name: String,
+ // Ideally we'd have a single callback that has an enum containing the request values,
+ // but NAPI doesn't support that just yet
+ #[napi(
+ ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void"
+ )]
+ registration_callback: ThreadsafeFunction<
+ FnArgs<(u32, u32, PasskeyRegistrationRequest)>,
+ >,
+ #[napi(
+ ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void"
+ )]
+ assertion_callback: ThreadsafeFunction<
+ FnArgs<(u32, u32, PasskeyAssertionRequest)>,
+ >,
+ #[napi(
+ ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
+ )]
+ assertion_without_user_interface_callback: ThreadsafeFunction<
+ FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>,
+ >,
+ #[napi(
+ ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
+ )]
+ native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>,
+ ) -> napi::Result {
+ let (send, mut recv) = tokio::sync::mpsc::channel::(32);
+ tokio::spawn(async move {
+ while let Some(Message {
+ client_id,
+ kind,
+ message,
+ }) = recv.recv().await
+ {
+ match kind {
+ // TODO: We're ignoring the connection and disconnection messages for now
+ MessageType::Connected | MessageType::Disconnected => continue,
+ MessageType::Message => {
+ let Some(message) = message else {
+ error!("Message is empty");
+ continue;
+ };
+
+ match serde_json::from_str::>(
+ &message,
+ ) {
+ Ok(msg) => {
+ let value = msg
+ .value
+ .map(|value| (client_id, msg.sequence_number, value).into())
+ .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
+
+ assertion_callback
+ .call(value, ThreadsafeFunctionCallMode::NonBlocking);
+ continue;
+ }
+ Err(e) => {
+ error!(error = %e, "Error deserializing message1");
+ }
+ }
+
+ match serde_json::from_str::<
+ PasskeyMessage,
+ >(&message)
+ {
+ Ok(msg) => {
+ let value = msg
+ .value
+ .map(|value| (client_id, msg.sequence_number, value).into())
+ .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
+
+ assertion_without_user_interface_callback
+ .call(value, ThreadsafeFunctionCallMode::NonBlocking);
+ continue;
+ }
+ Err(e) => {
+ error!(error = %e, "Error deserializing message1");
+ }
+ }
+
+ match serde_json::from_str::>(
+ &message,
+ ) {
+ Ok(msg) => {
+ let value = msg
+ .value
+ .map(|value| (client_id, msg.sequence_number, value).into())
+ .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
+ registration_callback
+ .call(value, ThreadsafeFunctionCallMode::NonBlocking);
+ continue;
+ }
+ Err(e) => {
+ error!(error = %e, "Error deserializing message2");
+ }
+ }
+
+ match serde_json::from_str::>(&message) {
+ Ok(msg) => {
+ let value = msg
+ .value
+ .map(|value| (client_id, msg.sequence_number, value))
+ .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
+ native_status_callback
+ .call(value, ThreadsafeFunctionCallMode::NonBlocking);
+ continue;
+ }
+ Err(error) => {
+ error!(%error, "Unable to deserialze native status.");
+ }
+ }
+
+ error!(message, "Received an unknown message2");
+ }
+ }
+ }
+ });
+
+ let path = desktop_core::ipc::path(&name);
+
+ let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
+ napi::Error::from_reason(format!(
+ "Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
+ ))
+ })?;
+
+ Ok(AutofillIpcServer { server })
+ }
+
+ /// Return the path to the IPC server.
+ #[napi]
+ pub fn get_path(&self) -> String {
+ self.server.path.to_string_lossy().to_string()
+ }
+
+ /// Stop the IPC server.
+ #[napi]
+ pub fn stop(&self) -> napi::Result<()> {
+ self.server.stop();
+ Ok(())
+ }
+
+ #[napi]
+ pub fn complete_registration(
+ &self,
+ client_id: u32,
+ sequence_number: u32,
+ response: PasskeyRegistrationResponse,
+ ) -> napi::Result {
+ let message = PasskeyMessage {
+ sequence_number,
+ value: Ok(response),
+ };
+ self.send(client_id, serde_json::to_string(&message).unwrap())
+ }
+
+ #[napi]
+ pub fn complete_assertion(
+ &self,
+ client_id: u32,
+ sequence_number: u32,
+ response: PasskeyAssertionResponse,
+ ) -> napi::Result {
+ let message = PasskeyMessage {
+ sequence_number,
+ value: Ok(response),
+ };
+ self.send(client_id, serde_json::to_string(&message).unwrap())
+ }
+
+ #[napi]
+ pub fn complete_error(
+ &self,
+ client_id: u32,
+ sequence_number: u32,
+ error: String,
+ ) -> napi::Result {
+ let message: PasskeyMessage<()> = PasskeyMessage {
+ sequence_number,
+ value: Err(BitwardenError::Internal(error)),
+ };
+ self.send(client_id, serde_json::to_string(&message).unwrap())
+ }
+
+ // TODO: Add a way to send a message to a specific client?
+ fn send(&self, _client_id: u32, message: String) -> napi::Result {
+ self.server
+ .send(message)
+ .map_err(|e| {
+ napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
+ })
+ // NAPI doesn't support u64 or usize, so we need to convert to u32
+ .map(|u| u32::try_from(u).unwrap_or_default())
+ }
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/autostart.rs b/apps/desktop/desktop_native/napi/src/autostart.rs
new file mode 100644
index 00000000000..3068226809e
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/autostart.rs
@@ -0,0 +1,9 @@
+#[napi]
+pub mod autostart {
+ #[napi]
+ pub async fn set_autostart(autostart: bool, params: Vec) -> napi::Result<()> {
+ desktop_core::autostart::set_autostart(autostart, params)
+ .await
+ .map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}")))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/autotype.rs b/apps/desktop/desktop_native/napi/src/autotype.rs
new file mode 100644
index 00000000000..b63c95ceb5c
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/autotype.rs
@@ -0,0 +1,20 @@
+#[napi]
+pub mod autotype {
+ #[napi]
+ pub fn get_foreground_window_title() -> napi::Result {
+ autotype::get_foreground_window_title().map_err(|_| {
+ napi::Error::from_reason(
+ "Autotype Error: failed to get foreground window title".to_string(),
+ )
+ })
+ }
+
+ #[napi]
+ pub fn type_input(
+ input: Vec,
+ keyboard_shortcut: Vec,
+ ) -> napi::Result<(), napi::Status> {
+ autotype::type_input(&input, &keyboard_shortcut)
+ .map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}")))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/biometrics.rs b/apps/desktop/desktop_native/napi/src/biometrics.rs
new file mode 100644
index 00000000000..bca802d5884
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/biometrics.rs
@@ -0,0 +1,100 @@
+#[napi]
+pub mod biometrics {
+ use desktop_core::biometric::{Biometric, BiometricTrait};
+
+ // Prompt for biometric confirmation
+ #[napi]
+ pub async fn prompt(
+ hwnd: napi::bindgen_prelude::Buffer,
+ message: String,
+ ) -> napi::Result {
+ Biometric::prompt(hwnd.into(), message)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn available() -> napi::Result {
+ Biometric::available()
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn set_biometric_secret(
+ service: String,
+ account: String,
+ secret: String,
+ key_material: Option,
+ iv_b64: String,
+ ) -> napi::Result {
+ Biometric::set_biometric_secret(
+ &service,
+ &account,
+ &secret,
+ key_material.map(|m| m.into()),
+ &iv_b64,
+ )
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ /// Retrieves the biometric secret for the given service and account.
+ /// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
+ #[napi]
+ pub async fn get_biometric_secret(
+ service: String,
+ account: String,
+ key_material: Option,
+ ) -> napi::Result {
+ Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ /// Derives key material from biometric data. Returns a string encoded with a
+ /// base64 encoded key and the base64 encoded challenge used to create it
+ /// separated by a `|` character.
+ ///
+ /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will
+ /// be generated.
+ ///
+ /// `format!("|")`
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn derive_key_material(iv: Option) -> napi::Result {
+ Biometric::derive_key_material(iv.as_deref())
+ .map(|k| k.into())
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi(object)]
+ pub struct KeyMaterial {
+ pub os_key_part_b64: String,
+ pub client_key_part_b64: Option,
+ }
+
+ impl From for desktop_core::biometric::KeyMaterial {
+ fn from(km: KeyMaterial) -> Self {
+ desktop_core::biometric::KeyMaterial {
+ os_key_part_b64: km.os_key_part_b64,
+ client_key_part_b64: km.client_key_part_b64,
+ }
+ }
+ }
+
+ #[napi(object)]
+ pub struct OsDerivedKey {
+ pub key_b64: String,
+ pub iv_b64: String,
+ }
+
+ impl From for OsDerivedKey {
+ fn from(km: desktop_core::biometric::OsDerivedKey) -> Self {
+ OsDerivedKey {
+ key_b64: km.key_b64,
+ iv_b64: km.iv_b64,
+ }
+ }
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/biometrics_v2.rs b/apps/desktop/desktop_native/napi/src/biometrics_v2.rs
new file mode 100644
index 00000000000..2df3a6a07be
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/biometrics_v2.rs
@@ -0,0 +1,116 @@
+#[napi]
+pub mod biometrics_v2 {
+ use desktop_core::biometric_v2::BiometricTrait;
+
+ #[napi]
+ pub struct BiometricLockSystem {
+ inner: desktop_core::biometric_v2::BiometricLockSystem,
+ }
+
+ #[napi]
+ pub fn init_biometric_system() -> napi::Result {
+ Ok(BiometricLockSystem {
+ inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
+ })
+ }
+
+ #[napi]
+ pub async fn authenticate(
+ biometric_lock_system: &BiometricLockSystem,
+ hwnd: napi::bindgen_prelude::Buffer,
+ message: String,
+ ) -> napi::Result {
+ biometric_lock_system
+ .inner
+ .authenticate(hwnd.into(), message)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn authenticate_available(
+ biometric_lock_system: &BiometricLockSystem,
+ ) -> napi::Result {
+ biometric_lock_system
+ .inner
+ .authenticate_available()
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn enroll_persistent(
+ biometric_lock_system: &BiometricLockSystem,
+ user_id: String,
+ key: napi::bindgen_prelude::Buffer,
+ ) -> napi::Result<()> {
+ biometric_lock_system
+ .inner
+ .enroll_persistent(&user_id, &key)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn provide_key(
+ biometric_lock_system: &BiometricLockSystem,
+ user_id: String,
+ key: napi::bindgen_prelude::Buffer,
+ ) -> napi::Result<()> {
+ biometric_lock_system
+ .inner
+ .provide_key(&user_id, &key)
+ .await;
+ Ok(())
+ }
+
+ #[napi]
+ pub async fn unlock(
+ biometric_lock_system: &BiometricLockSystem,
+ user_id: String,
+ hwnd: napi::bindgen_prelude::Buffer,
+ ) -> napi::Result {
+ biometric_lock_system
+ .inner
+ .unlock(&user_id, hwnd.into())
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ .map(|v| v.into())
+ }
+
+ #[napi]
+ pub async fn unlock_available(
+ biometric_lock_system: &BiometricLockSystem,
+ user_id: String,
+ ) -> napi::Result {
+ biometric_lock_system
+ .inner
+ .unlock_available(&user_id)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn has_persistent(
+ biometric_lock_system: &BiometricLockSystem,
+ user_id: String,
+ ) -> napi::Result {
+ biometric_lock_system
+ .inner
+ .has_persistent(&user_id)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn unenroll(
+ biometric_lock_system: &BiometricLockSystem,
+ user_id: String,
+ ) -> napi::Result<()> {
+ biometric_lock_system
+ .inner
+ .unenroll(&user_id)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/chromium_importer.rs b/apps/desktop/desktop_native/napi/src/chromium_importer.rs
new file mode 100644
index 00000000000..da295984a47
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/chromium_importer.rs
@@ -0,0 +1,116 @@
+#[napi]
+pub mod chromium_importer {
+ use std::collections::HashMap;
+
+ use chromium_importer::{
+ chromium::{
+ DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult,
+ ProfileInfo as _ProfileInfo,
+ },
+ metadata::NativeImporterMetadata as _NativeImporterMetadata,
+ };
+
+ #[napi(object)]
+ pub struct ProfileInfo {
+ pub id: String,
+ pub name: String,
+ }
+
+ #[napi(object)]
+ pub struct Login {
+ pub url: String,
+ pub username: String,
+ pub password: String,
+ pub note: String,
+ }
+
+ #[napi(object)]
+ pub struct LoginImportFailure {
+ pub url: String,
+ pub username: String,
+ pub error: String,
+ }
+
+ #[napi(object)]
+ pub struct LoginImportResult {
+ pub login: Option,
+ pub failure: Option,
+ }
+
+ #[napi(object)]
+ pub struct NativeImporterMetadata {
+ pub id: String,
+ pub loaders: Vec,
+ pub instructions: String,
+ }
+
+ impl From<_LoginImportResult> for LoginImportResult {
+ fn from(l: _LoginImportResult) -> Self {
+ match l {
+ _LoginImportResult::Success(l) => LoginImportResult {
+ login: Some(Login {
+ url: l.url,
+ username: l.username,
+ password: l.password,
+ note: l.note,
+ }),
+ failure: None,
+ },
+ _LoginImportResult::Failure(l) => LoginImportResult {
+ login: None,
+ failure: Some(LoginImportFailure {
+ url: l.url,
+ username: l.username,
+ error: l.error,
+ }),
+ },
+ }
+ }
+ }
+
+ impl From<_ProfileInfo> for ProfileInfo {
+ fn from(p: _ProfileInfo) -> Self {
+ ProfileInfo {
+ id: p.folder,
+ name: p.name,
+ }
+ }
+ }
+
+ impl From<_NativeImporterMetadata> for NativeImporterMetadata {
+ fn from(m: _NativeImporterMetadata) -> Self {
+ NativeImporterMetadata {
+ id: m.id,
+ loaders: m.loaders,
+ instructions: m.instructions,
+ }
+ }
+ }
+
+ #[napi]
+ /// Returns OS aware metadata describing supported Chromium based importers as a JSON string.
+ pub fn get_metadata() -> HashMap {
+ chromium_importer::metadata::get_supported_importers::()
+ .into_iter()
+ .map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata)))
+ .collect()
+ }
+
+ #[napi]
+ pub fn get_available_profiles(browser: String) -> napi::Result> {
+ chromium_importer::chromium::get_available_profiles(&browser)
+ .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect())
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub async fn import_logins(
+ browser: String,
+ profile_id: String,
+ ) -> napi::Result> {
+ chromium_importer::chromium::import_logins(&browser, &profile_id)
+ .await
+ .map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/clipboards.rs b/apps/desktop/desktop_native/napi/src/clipboards.rs
new file mode 100644
index 00000000000..810e457dd60
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/clipboards.rs
@@ -0,0 +1,15 @@
+#[napi]
+pub mod clipboards {
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn read() -> napi::Result {
+ desktop_core::clipboard::read().map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn write(text: String, password: bool) -> napi::Result<()> {
+ desktop_core::clipboard::write(&text, password)
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/ipc.rs b/apps/desktop/desktop_native/napi/src/ipc.rs
new file mode 100644
index 00000000000..ba72b1dce2b
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/ipc.rs
@@ -0,0 +1,106 @@
+#[napi]
+pub mod ipc {
+ use desktop_core::ipc::server::{Message, MessageType};
+ use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
+
+ #[napi(object)]
+ pub struct IpcMessage {
+ pub client_id: u32,
+ pub kind: IpcMessageType,
+ pub message: Option,
+ }
+
+ impl From for IpcMessage {
+ fn from(message: Message) -> Self {
+ IpcMessage {
+ client_id: message.client_id,
+ kind: message.kind.into(),
+ message: message.message,
+ }
+ }
+ }
+
+ #[napi]
+ pub enum IpcMessageType {
+ Connected,
+ Disconnected,
+ Message,
+ }
+
+ impl From for IpcMessageType {
+ fn from(message_type: MessageType) -> Self {
+ match message_type {
+ MessageType::Connected => IpcMessageType::Connected,
+ MessageType::Disconnected => IpcMessageType::Disconnected,
+ MessageType::Message => IpcMessageType::Message,
+ }
+ }
+ }
+
+ #[napi]
+ pub struct NativeIpcServer {
+ server: desktop_core::ipc::server::Server,
+ }
+
+ #[napi]
+ impl NativeIpcServer {
+ /// Create and start the IPC server without blocking.
+ ///
+ /// @param name The endpoint name to listen on. This name uniquely identifies the IPC
+ /// connection and must be the same for both the server and client. @param callback
+ /// This function will be called whenever a message is received from a client.
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi(factory)]
+ pub async fn listen(
+ name: String,
+ #[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")]
+ callback: ThreadsafeFunction,
+ ) -> napi::Result {
+ let (send, mut recv) = tokio::sync::mpsc::channel::(32);
+ tokio::spawn(async move {
+ while let Some(message) = recv.recv().await {
+ callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
+ }
+ });
+
+ let path = desktop_core::ipc::path(&name);
+
+ let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
+ napi::Error::from_reason(format!(
+ "Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
+ ))
+ })?;
+
+ Ok(NativeIpcServer { server })
+ }
+
+ /// Return the path to the IPC server.
+ #[napi]
+ pub fn get_path(&self) -> String {
+ self.server.path.to_string_lossy().to_string()
+ }
+
+ /// Stop the IPC server.
+ #[napi]
+ pub fn stop(&self) -> napi::Result<()> {
+ self.server.stop();
+ Ok(())
+ }
+
+ /// Send a message over the IPC server to all the connected clients
+ ///
+ /// @return The number of clients that the message was sent to. Note that the number of
+ /// messages actually received may be less, as some clients could disconnect before
+ /// receiving the message.
+ #[napi]
+ pub fn send(&self, message: String) -> napi::Result {
+ self.server
+ .send(message)
+ .map_err(|e| {
+ napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
+ })
+ // NAPI doesn't support u64 or usize, so we need to convert to u32
+ .map(|u| u32::try_from(u).unwrap_or_default())
+ }
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs
index 588f757631c..e3abfd50e7a 100644
--- a/apps/desktop/desktop_native/napi/src/lib.rs
+++ b/apps/desktop/desktop_native/napi/src/lib.rs
@@ -4,1244 +4,22 @@ extern crate napi_derive;
mod passkey_authenticator_internal;
mod registry;
-#[napi]
-pub mod passwords {
- /// The error message returned when a password is not found during retrieval or deletion.
- #[napi]
- pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND;
-
- /// Fetch the stored password from the keychain.
- /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
- #[napi]
- pub async fn get_password(service: String, account: String) -> napi::Result {
- desktop_core::password::get_password(&service, &account)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- /// Save the password to the keychain. Adds an entry if none exists otherwise updates the
- /// existing entry.
- #[napi]
- pub async fn set_password(
- service: String,
- account: String,
- password: String,
- ) -> napi::Result<()> {
- desktop_core::password::set_password(&service, &account, &password)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- /// Delete the stored password from the keychain.
- /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
- #[napi]
- pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
- desktop_core::password::delete_password(&service, &account)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- /// Checks if the os secure storage is available
- #[napi]
- pub async fn is_available() -> napi::Result {
- desktop_core::password::is_available()
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod biometrics {
- use desktop_core::biometric::{Biometric, BiometricTrait};
-
- // Prompt for biometric confirmation
- #[napi]
- pub async fn prompt(
- hwnd: napi::bindgen_prelude::Buffer,
- message: String,
- ) -> napi::Result {
- Biometric::prompt(hwnd.into(), message)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn available() -> napi::Result {
- Biometric::available()
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn set_biometric_secret(
- service: String,
- account: String,
- secret: String,
- key_material: Option,
- iv_b64: String,
- ) -> napi::Result {
- Biometric::set_biometric_secret(
- &service,
- &account,
- &secret,
- key_material.map(|m| m.into()),
- &iv_b64,
- )
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- /// Retrieves the biometric secret for the given service and account.
- /// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
- #[napi]
- pub async fn get_biometric_secret(
- service: String,
- account: String,
- key_material: Option,
- ) -> napi::Result {
- Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- /// Derives key material from biometric data. Returns a string encoded with a
- /// base64 encoded key and the base64 encoded challenge used to create it
- /// separated by a `|` character.
- ///
- /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will
- /// be generated.
- ///
- /// `format!("|")`
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn derive_key_material(iv: Option) -> napi::Result {
- Biometric::derive_key_material(iv.as_deref())
- .map(|k| k.into())
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi(object)]
- pub struct KeyMaterial {
- pub os_key_part_b64: String,
- pub client_key_part_b64: Option,
- }
-
- impl From for desktop_core::biometric::KeyMaterial {
- fn from(km: KeyMaterial) -> Self {
- desktop_core::biometric::KeyMaterial {
- os_key_part_b64: km.os_key_part_b64,
- client_key_part_b64: km.client_key_part_b64,
- }
- }
- }
-
- #[napi(object)]
- pub struct OsDerivedKey {
- pub key_b64: String,
- pub iv_b64: String,
- }
-
- impl From for OsDerivedKey {
- fn from(km: desktop_core::biometric::OsDerivedKey) -> Self {
- OsDerivedKey {
- key_b64: km.key_b64,
- iv_b64: km.iv_b64,
- }
- }
- }
-}
-
-#[napi]
-pub mod biometrics_v2 {
- use desktop_core::biometric_v2::BiometricTrait;
-
- #[napi]
- pub struct BiometricLockSystem {
- inner: desktop_core::biometric_v2::BiometricLockSystem,
- }
-
- #[napi]
- pub fn init_biometric_system() -> napi::Result {
- Ok(BiometricLockSystem {
- inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
- })
- }
-
- #[napi]
- pub async fn authenticate(
- biometric_lock_system: &BiometricLockSystem,
- hwnd: napi::bindgen_prelude::Buffer,
- message: String,
- ) -> napi::Result {
- biometric_lock_system
- .inner
- .authenticate(hwnd.into(), message)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn authenticate_available(
- biometric_lock_system: &BiometricLockSystem,
- ) -> napi::Result {
- biometric_lock_system
- .inner
- .authenticate_available()
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn enroll_persistent(
- biometric_lock_system: &BiometricLockSystem,
- user_id: String,
- key: napi::bindgen_prelude::Buffer,
- ) -> napi::Result<()> {
- biometric_lock_system
- .inner
- .enroll_persistent(&user_id, &key)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn provide_key(
- biometric_lock_system: &BiometricLockSystem,
- user_id: String,
- key: napi::bindgen_prelude::Buffer,
- ) -> napi::Result<()> {
- biometric_lock_system
- .inner
- .provide_key(&user_id, &key)
- .await;
- Ok(())
- }
-
- #[napi]
- pub async fn unlock(
- biometric_lock_system: &BiometricLockSystem,
- user_id: String,
- hwnd: napi::bindgen_prelude::Buffer,
- ) -> napi::Result {
- biometric_lock_system
- .inner
- .unlock(&user_id, hwnd.into())
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- .map(|v| v.into())
- }
-
- #[napi]
- pub async fn unlock_available(
- biometric_lock_system: &BiometricLockSystem,
- user_id: String,
- ) -> napi::Result {
- biometric_lock_system
- .inner
- .unlock_available(&user_id)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn has_persistent(
- biometric_lock_system: &BiometricLockSystem,
- user_id: String,
- ) -> napi::Result {
- biometric_lock_system
- .inner
- .has_persistent(&user_id)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn unenroll(
- biometric_lock_system: &BiometricLockSystem,
- user_id: String,
- ) -> napi::Result<()> {
- biometric_lock_system
- .inner
- .unenroll(&user_id)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod clipboards {
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn read() -> napi::Result {
- desktop_core::clipboard::read().map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn write(text: String, password: bool) -> napi::Result<()> {
- desktop_core::clipboard::write(&text, password)
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod sshagent {
- use std::sync::Arc;
-
- use napi::{
- bindgen_prelude::Promise,
- threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
- };
- use tokio::{self, sync::Mutex};
- use tracing::error;
-
- #[napi]
- pub struct SshAgentState {
- state: desktop_core::ssh_agent::BitwardenDesktopAgent,
- }
-
- #[napi(object)]
- pub struct PrivateKey {
- pub private_key: String,
- pub name: String,
- pub cipher_id: String,
- }
-
- #[napi(object)]
- pub struct SshKey {
- pub private_key: String,
- pub public_key: String,
- pub key_fingerprint: String,
- }
-
- #[napi(object)]
- pub struct SshUIRequest {
- pub cipher_id: Option,
- pub is_list: bool,
- pub process_name: String,
- pub is_forwarding: bool,
- pub namespace: Option,
- }
-
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn serve(
- callback: ThreadsafeFunction>,
- ) -> napi::Result {
- let (auth_request_tx, mut auth_request_rx) =
- tokio::sync::mpsc::channel::(32);
- let (auth_response_tx, auth_response_rx) =
- tokio::sync::broadcast::channel::<(u32, bool)>(32);
- let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
- // Wrap callback in Arc so it can be shared across spawned tasks
- let callback = Arc::new(callback);
- tokio::spawn(async move {
- let _ = auth_response_rx;
-
- while let Some(request) = auth_request_rx.recv().await {
- let cloned_response_tx_arc = auth_response_tx_arc.clone();
- let cloned_callback = callback.clone();
- tokio::spawn(async move {
- let auth_response_tx_arc = cloned_response_tx_arc;
- let callback = cloned_callback;
- // In NAPI v3, obtain the JS callback return as a Promise and await it
- // in Rust
- let (tx, rx) = std::sync::mpsc::channel::>();
- let status = callback.call_with_return_value(
- Ok(SshUIRequest {
- cipher_id: request.cipher_id,
- is_list: request.is_list,
- process_name: request.process_name,
- is_forwarding: request.is_forwarding,
- namespace: request.namespace,
- }),
- ThreadsafeFunctionCallMode::Blocking,
- move |ret: Result, napi::Error>, _env| {
- if let Ok(p) = ret {
- let _ = tx.send(p);
- }
- Ok(())
- },
- );
-
- let result = if status == napi::Status::Ok {
- match rx.recv() {
- Ok(promise) => match promise.await {
- Ok(v) => v,
- Err(e) => {
- error!(error = %e, "UI callback promise rejected");
- false
- }
- },
- Err(e) => {
- error!(error = %e, "Failed to receive UI callback promise");
- false
- }
- }
- } else {
- error!(error = ?status, "Calling UI callback failed");
- false
- };
-
- let _ = auth_response_tx_arc
- .lock()
- .await
- .send((request.request_id, result))
- .expect("should be able to send auth response to agent");
- });
- }
- });
-
- match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server(
- auth_request_tx,
- Arc::new(Mutex::new(auth_response_rx)),
- ) {
- Ok(state) => Ok(SshAgentState { state }),
- Err(e) => Err(napi::Error::from_reason(e.to_string())),
- }
- }
-
- #[napi]
- pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
- let bitwarden_agent_state = &mut agent_state.state;
- bitwarden_agent_state.stop();
- Ok(())
- }
-
- #[napi]
- pub fn is_running(agent_state: &mut SshAgentState) -> bool {
- let bitwarden_agent_state = agent_state.state.clone();
- bitwarden_agent_state.is_running()
- }
-
- #[napi]
- pub fn set_keys(
- agent_state: &mut SshAgentState,
- new_keys: Vec,
- ) -> napi::Result<()> {
- let bitwarden_agent_state = &mut agent_state.state;
- bitwarden_agent_state
- .set_keys(
- new_keys
- .iter()
- .map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone()))
- .collect(),
- )
- .map_err(|e| napi::Error::from_reason(e.to_string()))?;
- Ok(())
- }
-
- #[napi]
- pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
- let bitwarden_agent_state = &mut agent_state.state;
- bitwarden_agent_state
- .lock()
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
- let bitwarden_agent_state = &mut agent_state.state;
- bitwarden_agent_state
- .clear_keys()
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod processisolations {
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn disable_coredumps() -> napi::Result<()> {
- desktop_core::process_isolation::disable_coredumps()
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn is_core_dumping_disabled() -> napi::Result {
- desktop_core::process_isolation::is_core_dumping_disabled()
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn isolate_process() -> napi::Result<()> {
- desktop_core::process_isolation::isolate_process()
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod powermonitors {
- use napi::{
- threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
- tokio,
- };
-
- #[napi]
- pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> {
- let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
- desktop_core::powermonitor::on_lock(tx)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))?;
- tokio::spawn(async move {
- while let Some(()) = rx.recv().await {
- callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
- }
- });
- Ok(())
- }
-
- #[napi]
- pub async fn is_lock_monitor_available() -> napi::Result {
- Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
- }
-}
-
-#[napi]
-pub mod windows_registry {
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn create_key(key: String, subkey: String, value: String) -> napi::Result<()> {
- crate::registry::create_key(&key, &subkey, &value)
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi]
- pub async fn delete_key(key: String, subkey: String) -> napi::Result<()> {
- crate::registry::delete_key(&key, &subkey)
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod ipc {
- use desktop_core::ipc::server::{Message, MessageType};
- use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
-
- #[napi(object)]
- pub struct IpcMessage {
- pub client_id: u32,
- pub kind: IpcMessageType,
- pub message: Option,
- }
-
- impl From for IpcMessage {
- fn from(message: Message) -> Self {
- IpcMessage {
- client_id: message.client_id,
- kind: message.kind.into(),
- message: message.message,
- }
- }
- }
-
- #[napi]
- pub enum IpcMessageType {
- Connected,
- Disconnected,
- Message,
- }
-
- impl From for IpcMessageType {
- fn from(message_type: MessageType) -> Self {
- match message_type {
- MessageType::Connected => IpcMessageType::Connected,
- MessageType::Disconnected => IpcMessageType::Disconnected,
- MessageType::Message => IpcMessageType::Message,
- }
- }
- }
-
- #[napi]
- pub struct NativeIpcServer {
- server: desktop_core::ipc::server::Server,
- }
-
- #[napi]
- impl NativeIpcServer {
- /// Create and start the IPC server without blocking.
- ///
- /// @param name The endpoint name to listen on. This name uniquely identifies the IPC
- /// connection and must be the same for both the server and client. @param callback
- /// This function will be called whenever a message is received from a client.
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi(factory)]
- pub async fn listen(
- name: String,
- #[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")]
- callback: ThreadsafeFunction,
- ) -> napi::Result {
- let (send, mut recv) = tokio::sync::mpsc::channel::(32);
- tokio::spawn(async move {
- while let Some(message) = recv.recv().await {
- callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
- }
- });
-
- let path = desktop_core::ipc::path(&name);
-
- let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
- napi::Error::from_reason(format!(
- "Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
- ))
- })?;
-
- Ok(NativeIpcServer { server })
- }
-
- /// Return the path to the IPC server.
- #[napi]
- pub fn get_path(&self) -> String {
- self.server.path.to_string_lossy().to_string()
- }
-
- /// Stop the IPC server.
- #[napi]
- pub fn stop(&self) -> napi::Result<()> {
- self.server.stop();
- Ok(())
- }
-
- /// Send a message over the IPC server to all the connected clients
- ///
- /// @return The number of clients that the message was sent to. Note that the number of
- /// messages actually received may be less, as some clients could disconnect before
- /// receiving the message.
- #[napi]
- pub fn send(&self, message: String) -> napi::Result {
- self.server
- .send(message)
- .map_err(|e| {
- napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
- })
- // NAPI doesn't support u64 or usize, so we need to convert to u32
- .map(|u| u32::try_from(u).unwrap_or_default())
- }
- }
-}
-
-#[napi]
-pub mod autostart {
- #[napi]
- pub async fn set_autostart(autostart: bool, params: Vec) -> napi::Result<()> {
- desktop_core::autostart::set_autostart(autostart, params)
- .await
- .map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}")))
- }
-}
-
-#[napi]
-pub mod autofill {
- use desktop_core::ipc::server::{Message, MessageType};
- use napi::{
- bindgen_prelude::FnArgs,
- threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
- };
- use serde::{de::DeserializeOwned, Deserialize, Serialize};
- use tracing::error;
-
- #[napi]
- pub async fn run_command(value: String) -> napi::Result {
- desktop_core::autofill::run_command(value)
- .await
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[derive(Debug, serde::Serialize, serde:: Deserialize)]
- pub enum BitwardenError {
- Internal(String),
- }
-
- #[napi(string_enum)]
- #[derive(Debug, Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub enum UserVerification {
- #[napi(value = "preferred")]
- Preferred,
- #[napi(value = "required")]
- Required,
- #[napi(value = "discouraged")]
- Discouraged,
- }
-
- #[derive(Serialize, Deserialize)]
- #[serde(bound = "T: Serialize + DeserializeOwned")]
- pub struct PasskeyMessage {
- pub sequence_number: u32,
- pub value: Result,
- }
-
- #[napi(object)]
- #[derive(Debug, Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct Position {
- pub x: i32,
- pub y: i32,
- }
-
- #[napi(object)]
- #[derive(Debug, Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct PasskeyRegistrationRequest {
- pub rp_id: String,
- pub user_name: String,
- pub user_handle: Vec,
- pub client_data_hash: Vec,
- pub user_verification: UserVerification,
- pub supported_algorithms: Vec,
- pub window_xy: Position,
- pub excluded_credentials: Vec>,
- }
-
- #[napi(object)]
- #[derive(Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct PasskeyRegistrationResponse {
- pub rp_id: String,
- pub client_data_hash: Vec,
- pub credential_id: Vec,
- pub attestation_object: Vec,
- }
-
- #[napi(object)]
- #[derive(Debug, Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct PasskeyAssertionRequest {
- pub rp_id: String,
- pub client_data_hash: Vec,
- pub user_verification: UserVerification,
- pub allowed_credentials: Vec>,
- pub window_xy: Position,
- //extension_input: Vec, TODO: Implement support for extensions
- }
-
- #[napi(object)]
- #[derive(Debug, Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct PasskeyAssertionWithoutUserInterfaceRequest {
- pub rp_id: String,
- pub credential_id: Vec,
- pub user_name: String,
- pub user_handle: Vec,
- pub record_identifier: Option,
- pub client_data_hash: Vec,
- pub user_verification: UserVerification,
- pub window_xy: Position,
- }
-
- #[napi(object)]
- #[derive(Debug, Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct NativeStatus {
- pub key: String,
- pub value: String,
- }
-
- #[napi(object)]
- #[derive(Serialize, Deserialize)]
- #[serde(rename_all = "camelCase")]
- pub struct PasskeyAssertionResponse {
- pub rp_id: String,
- pub user_handle: Vec,
- pub signature: Vec,
- pub client_data_hash: Vec,
- pub authenticator_data: Vec,
- pub credential_id: Vec,
- }
-
- #[napi]
- pub struct AutofillIpcServer {
- server: desktop_core::ipc::server::Server,
- }
-
- // FIXME: Remove unwraps! They panic and terminate the whole application.
- #[allow(clippy::unwrap_used)]
- #[napi]
- impl AutofillIpcServer {
- /// Create and start the IPC server without blocking.
- ///
- /// @param name The endpoint name to listen on. This name uniquely identifies the IPC
- /// connection and must be the same for both the server and client. @param callback
- /// This function will be called whenever a message is received from a client.
- #[allow(clippy::unused_async)] // FIXME: Remove unused async!
- #[napi(factory)]
- pub async fn listen(
- name: String,
- // Ideally we'd have a single callback that has an enum containing the request values,
- // but NAPI doesn't support that just yet
- #[napi(
- ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void"
- )]
- registration_callback: ThreadsafeFunction<
- FnArgs<(u32, u32, PasskeyRegistrationRequest)>,
- >,
- #[napi(
- ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void"
- )]
- assertion_callback: ThreadsafeFunction<
- FnArgs<(u32, u32, PasskeyAssertionRequest)>,
- >,
- #[napi(
- ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
- )]
- assertion_without_user_interface_callback: ThreadsafeFunction<
- FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>,
- >,
- #[napi(
- ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
- )]
- native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>,
- ) -> napi::Result {
- let (send, mut recv) = tokio::sync::mpsc::channel::(32);
- tokio::spawn(async move {
- while let Some(Message {
- client_id,
- kind,
- message,
- }) = recv.recv().await
- {
- match kind {
- // TODO: We're ignoring the connection and disconnection messages for now
- MessageType::Connected | MessageType::Disconnected => continue,
- MessageType::Message => {
- let Some(message) = message else {
- error!("Message is empty");
- continue;
- };
-
- match serde_json::from_str::>(
- &message,
- ) {
- Ok(msg) => {
- let value = msg
- .value
- .map(|value| (client_id, msg.sequence_number, value).into())
- .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
-
- assertion_callback
- .call(value, ThreadsafeFunctionCallMode::NonBlocking);
- continue;
- }
- Err(e) => {
- error!(error = %e, "Error deserializing message1");
- }
- }
-
- match serde_json::from_str::<
- PasskeyMessage,
- >(&message)
- {
- Ok(msg) => {
- let value = msg
- .value
- .map(|value| (client_id, msg.sequence_number, value).into())
- .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
-
- assertion_without_user_interface_callback
- .call(value, ThreadsafeFunctionCallMode::NonBlocking);
- continue;
- }
- Err(e) => {
- error!(error = %e, "Error deserializing message1");
- }
- }
-
- match serde_json::from_str::>(
- &message,
- ) {
- Ok(msg) => {
- let value = msg
- .value
- .map(|value| (client_id, msg.sequence_number, value).into())
- .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
- registration_callback
- .call(value, ThreadsafeFunctionCallMode::NonBlocking);
- continue;
- }
- Err(e) => {
- error!(error = %e, "Error deserializing message2");
- }
- }
-
- match serde_json::from_str::>(&message) {
- Ok(msg) => {
- let value = msg
- .value
- .map(|value| (client_id, msg.sequence_number, value))
- .map_err(|e| napi::Error::from_reason(format!("{e:?}")));
- native_status_callback
- .call(value, ThreadsafeFunctionCallMode::NonBlocking);
- continue;
- }
- Err(error) => {
- error!(%error, "Unable to deserialze native status.");
- }
- }
-
- error!(message, "Received an unknown message2");
- }
- }
- }
- });
-
- let path = desktop_core::ipc::path(&name);
-
- let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| {
- napi::Error::from_reason(format!(
- "Error listening to server - Path: {path:?} - Error: {e} - {e:?}"
- ))
- })?;
-
- Ok(AutofillIpcServer { server })
- }
-
- /// Return the path to the IPC server.
- #[napi]
- pub fn get_path(&self) -> String {
- self.server.path.to_string_lossy().to_string()
- }
-
- /// Stop the IPC server.
- #[napi]
- pub fn stop(&self) -> napi::Result<()> {
- self.server.stop();
- Ok(())
- }
-
- #[napi]
- pub fn complete_registration(
- &self,
- client_id: u32,
- sequence_number: u32,
- response: PasskeyRegistrationResponse,
- ) -> napi::Result {
- let message = PasskeyMessage {
- sequence_number,
- value: Ok(response),
- };
- self.send(client_id, serde_json::to_string(&message).unwrap())
- }
-
- #[napi]
- pub fn complete_assertion(
- &self,
- client_id: u32,
- sequence_number: u32,
- response: PasskeyAssertionResponse,
- ) -> napi::Result {
- let message = PasskeyMessage {
- sequence_number,
- value: Ok(response),
- };
- self.send(client_id, serde_json::to_string(&message).unwrap())
- }
-
- #[napi]
- pub fn complete_error(
- &self,
- client_id: u32,
- sequence_number: u32,
- error: String,
- ) -> napi::Result {
- let message: PasskeyMessage<()> = PasskeyMessage {
- sequence_number,
- value: Err(BitwardenError::Internal(error)),
- };
- self.send(client_id, serde_json::to_string(&message).unwrap())
- }
-
- // TODO: Add a way to send a message to a specific client?
- fn send(&self, _client_id: u32, message: String) -> napi::Result {
- self.server
- .send(message)
- .map_err(|e| {
- napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}"))
- })
- // NAPI doesn't support u64 or usize, so we need to convert to u32
- .map(|u| u32::try_from(u).unwrap_or_default())
- }
- }
-}
-
-#[napi]
-pub mod passkey_authenticator {
- #[napi]
- pub fn register() -> napi::Result<()> {
- crate::passkey_authenticator_internal::register().map_err(|e| {
- napi::Error::from_reason(format!("Passkey registration failed - Error: {e} - {e:?}"))
- })
- }
-}
-
-#[napi]
-pub mod logging {
- //! `logging` is the interface between the native desktop's usage of the `tracing` crate
- //! for logging, to intercept events and write to the JS space.
- //!
- //! # Example
- //!
- //! [Elec] 14:34:03.517 › [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting
- //! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock}
-
- use std::{fmt::Write, sync::OnceLock};
-
- use napi::{
- bindgen_prelude::FnArgs,
- threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
- };
- use tracing::Level;
- use tracing_subscriber::{
- filter::EnvFilter,
- fmt::format::{DefaultVisitor, Writer},
- layer::SubscriberExt,
- util::SubscriberInitExt,
- Layer,
- };
-
- struct JsLogger(OnceLock>>);
- static JS_LOGGER: JsLogger = JsLogger(OnceLock::new());
-
- #[napi]
- pub enum LogLevel {
- Trace,
- Debug,
- Info,
- Warn,
- Error,
- }
-
- impl From<&Level> for LogLevel {
- fn from(level: &Level) -> Self {
- match *level {
- Level::TRACE => LogLevel::Trace,
- Level::DEBUG => LogLevel::Debug,
- Level::INFO => LogLevel::Info,
- Level::WARN => LogLevel::Warn,
- Level::ERROR => LogLevel::Error,
- }
- }
- }
-
- // JsLayer lets us intercept events and write them to the JS Logger.
- struct JsLayer;
-
- impl Layer for JsLayer
- where
- S: tracing::Subscriber,
- {
- // This function builds a log message buffer from the event data and
- // calls the JS logger with it.
- //
- // For example, this log call:
- //
- // ```
- // mod supreme {
- // mod module {
- // let foo = "bar";
- // info!(best_variable_name = %foo, "Foo done it again.");
- // }
- // }
- // ```
- //
- // , results in the following string:
- //
- // [INFO] supreme::module: Foo done it again. {best_variable_name=bar}
- fn on_event(
- &self,
- event: &tracing::Event<'_>,
- _ctx: tracing_subscriber::layer::Context<'_, S>,
- ) {
- let mut buffer = String::new();
-
- // create the preamble text that precedes the message and vars. e.g.:
- // [INFO] desktop_core::ssh_agent::platform_ssh_agent:
- let level = event.metadata().level().as_str();
- let module_path = event.metadata().module_path().unwrap_or_default();
-
- write!(&mut buffer, "[{level}] {module_path}:")
- .expect("Failed to write tracing event to buffer");
-
- let writer = Writer::new(&mut buffer);
-
- // DefaultVisitor adds the message and variables to the buffer
- let mut visitor = DefaultVisitor::new(writer, false);
- event.record(&mut visitor);
-
- let msg = (event.metadata().level().into(), buffer);
-
- if let Some(logger) = JS_LOGGER.0.get() {
- let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking);
- };
- }
- }
-
- #[napi]
- pub fn init_napi_log(js_log_fn: ThreadsafeFunction>) {
- let _ = JS_LOGGER.0.set(js_log_fn);
-
- // the log level hierarchy is determined by:
- // - if RUST_LOG is detected at runtime
- // - if RUST_LOG is provided at compile time
- // - default to INFO
- let filter = EnvFilter::builder()
- .with_default_directive(
- option_env!("RUST_LOG")
- .unwrap_or("info")
- .parse()
- .expect("should provide valid log level at compile time."),
- )
- // parse directives from the RUST_LOG environment variable,
- // overriding the default directive for matching targets.
- .from_env_lossy();
-
- // With the `tracing-log` feature enabled for the `tracing_subscriber`,
- // the registry below will initialize a log compatibility layer, which allows
- // the subscriber to consume log::Records as though they were tracing Events.
- // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init
- tracing_subscriber::registry()
- .with(filter)
- .with(JsLayer)
- .init();
- }
-}
-
-#[napi]
-pub mod chromium_importer {
- use std::collections::HashMap;
-
- use chromium_importer::{
- chromium::{
- DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult,
- ProfileInfo as _ProfileInfo,
- },
- metadata::NativeImporterMetadata as _NativeImporterMetadata,
- };
-
- #[napi(object)]
- pub struct ProfileInfo {
- pub id: String,
- pub name: String,
- }
-
- #[napi(object)]
- pub struct Login {
- pub url: String,
- pub username: String,
- pub password: String,
- pub note: String,
- }
-
- #[napi(object)]
- pub struct LoginImportFailure {
- pub url: String,
- pub username: String,
- pub error: String,
- }
-
- #[napi(object)]
- pub struct LoginImportResult {
- pub login: Option,
- pub failure: Option,
- }
-
- #[napi(object)]
- pub struct NativeImporterMetadata {
- pub id: String,
- pub loaders: Vec,
- pub instructions: String,
- }
-
- impl From<_LoginImportResult> for LoginImportResult {
- fn from(l: _LoginImportResult) -> Self {
- match l {
- _LoginImportResult::Success(l) => LoginImportResult {
- login: Some(Login {
- url: l.url,
- username: l.username,
- password: l.password,
- note: l.note,
- }),
- failure: None,
- },
- _LoginImportResult::Failure(l) => LoginImportResult {
- login: None,
- failure: Some(LoginImportFailure {
- url: l.url,
- username: l.username,
- error: l.error,
- }),
- },
- }
- }
- }
-
- impl From<_ProfileInfo> for ProfileInfo {
- fn from(p: _ProfileInfo) -> Self {
- ProfileInfo {
- id: p.folder,
- name: p.name,
- }
- }
- }
-
- impl From<_NativeImporterMetadata> for NativeImporterMetadata {
- fn from(m: _NativeImporterMetadata) -> Self {
- NativeImporterMetadata {
- id: m.id,
- loaders: m.loaders,
- instructions: m.instructions,
- }
- }
- }
-
- #[napi]
- /// Returns OS aware metadata describing supported Chromium based importers as a JSON string.
- pub fn get_metadata() -> HashMap {
- chromium_importer::metadata::get_supported_importers::()
- .into_iter()
- .map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata)))
- .collect()
- }
-
- #[napi]
- pub fn get_available_profiles(browser: String) -> napi::Result> {
- chromium_importer::chromium::get_available_profiles(&browser)
- .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect())
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-
- #[napi]
- pub async fn import_logins(
- browser: String,
- profile_id: String,
- ) -> napi::Result> {
- chromium_importer::chromium::import_logins(&browser, &profile_id)
- .await
- .map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
- .map_err(|e| napi::Error::from_reason(e.to_string()))
- }
-}
-
-#[napi]
-pub mod autotype {
- #[napi]
- pub fn get_foreground_window_title() -> napi::Result {
- autotype::get_foreground_window_title().map_err(|_| {
- napi::Error::from_reason(
- "Autotype Error: failed to get foreground window title".to_string(),
- )
- })
- }
-
- #[napi]
- pub fn type_input(
- input: Vec,
- keyboard_shortcut: Vec,
- ) -> napi::Result<(), napi::Status> {
- autotype::type_input(&input, &keyboard_shortcut)
- .map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}")))
- }
-}
+// NAPI namespaces
+// In each of these modules, the types are defined within a nested namespace of
+// the same name so that NAPI can export the TypeScript types within a
+// namespace.
+pub mod autofill;
+pub mod autostart;
+pub mod autotype;
+pub mod biometrics;
+pub mod biometrics_v2;
+pub mod chromium_importer;
+pub mod clipboards;
+pub mod ipc;
+pub mod logging;
+pub mod passkey_authenticator;
+pub mod passwords;
+pub mod powermonitors;
+pub mod processisolations;
+pub mod sshagent;
+pub mod windows_registry;
diff --git a/apps/desktop/desktop_native/napi/src/logging.rs b/apps/desktop/desktop_native/napi/src/logging.rs
new file mode 100644
index 00000000000..e5791065e4e
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/logging.rs
@@ -0,0 +1,131 @@
+#[napi]
+pub mod logging {
+ //! `logging` is the interface between the native desktop's usage of the `tracing` crate
+ //! for logging, to intercept events and write to the JS space.
+ //!
+ //! # Example
+ //!
+ //! [Elec] 14:34:03.517 › [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting
+ //! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock}
+
+ use std::{fmt::Write, sync::OnceLock};
+
+ use napi::{
+ bindgen_prelude::FnArgs,
+ threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
+ };
+ use tracing::Level;
+ use tracing_subscriber::{
+ filter::EnvFilter,
+ fmt::format::{DefaultVisitor, Writer},
+ layer::SubscriberExt,
+ util::SubscriberInitExt,
+ Layer,
+ };
+
+ struct JsLogger(OnceLock>>);
+ static JS_LOGGER: JsLogger = JsLogger(OnceLock::new());
+
+ #[napi]
+ pub enum LogLevel {
+ Trace,
+ Debug,
+ Info,
+ Warn,
+ Error,
+ }
+
+ impl From<&Level> for LogLevel {
+ fn from(level: &Level) -> Self {
+ match *level {
+ Level::TRACE => LogLevel::Trace,
+ Level::DEBUG => LogLevel::Debug,
+ Level::INFO => LogLevel::Info,
+ Level::WARN => LogLevel::Warn,
+ Level::ERROR => LogLevel::Error,
+ }
+ }
+ }
+
+ // JsLayer lets us intercept events and write them to the JS Logger.
+ struct JsLayer;
+
+ impl Layer for JsLayer
+ where
+ S: tracing::Subscriber,
+ {
+ // This function builds a log message buffer from the event data and
+ // calls the JS logger with it.
+ //
+ // For example, this log call:
+ //
+ // ```
+ // mod supreme {
+ // mod module {
+ // let foo = "bar";
+ // info!(best_variable_name = %foo, "Foo done it again.");
+ // }
+ // }
+ // ```
+ //
+ // , results in the following string:
+ //
+ // [INFO] supreme::module: Foo done it again. {best_variable_name=bar}
+ fn on_event(
+ &self,
+ event: &tracing::Event<'_>,
+ _ctx: tracing_subscriber::layer::Context<'_, S>,
+ ) {
+ let mut buffer = String::new();
+
+ // create the preamble text that precedes the message and vars. e.g.:
+ // [INFO] desktop_core::ssh_agent::platform_ssh_agent:
+ let level = event.metadata().level().as_str();
+ let module_path = event.metadata().module_path().unwrap_or_default();
+
+ write!(&mut buffer, "[{level}] {module_path}:")
+ .expect("Failed to write tracing event to buffer");
+
+ let writer = Writer::new(&mut buffer);
+
+ // DefaultVisitor adds the message and variables to the buffer
+ let mut visitor = DefaultVisitor::new(writer, false);
+ event.record(&mut visitor);
+
+ let msg = (event.metadata().level().into(), buffer);
+
+ if let Some(logger) = JS_LOGGER.0.get() {
+ let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking);
+ };
+ }
+ }
+
+ #[napi]
+ pub fn init_napi_log(js_log_fn: ThreadsafeFunction>) {
+ let _ = JS_LOGGER.0.set(js_log_fn);
+
+ // the log level hierarchy is determined by:
+ // - if RUST_LOG is detected at runtime
+ // - if RUST_LOG is provided at compile time
+ // - default to INFO
+ let filter = EnvFilter::builder()
+ .with_default_directive(
+ option_env!("RUST_LOG")
+ .unwrap_or("info")
+ .parse()
+ .expect("should provide valid log level at compile time."),
+ )
+ // parse directives from the RUST_LOG environment variable,
+ // overriding the default directive for matching targets.
+ .from_env_lossy();
+
+ // With the `tracing-log` feature enabled for the `tracing_subscriber`,
+ // the registry below will initialize a log compatibility layer, which allows
+ // the subscriber to consume log::Records as though they were tracing Events.
+ // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init
+ tracing_subscriber::registry()
+ .with(filter)
+ .with(JsLayer)
+ .init();
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/passkey_authenticator.rs b/apps/desktop/desktop_native/napi/src/passkey_authenticator.rs
new file mode 100644
index 00000000000..37796353b80
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/passkey_authenticator.rs
@@ -0,0 +1,9 @@
+#[napi]
+pub mod passkey_authenticator {
+ #[napi]
+ pub fn register() -> napi::Result<()> {
+ crate::passkey_authenticator_internal::register().map_err(|e| {
+ napi::Error::from_reason(format!("Passkey registration failed - Error: {e} - {e:?}"))
+ })
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/passwords.rs b/apps/desktop/desktop_native/napi/src/passwords.rs
new file mode 100644
index 00000000000..763f338b0cb
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/passwords.rs
@@ -0,0 +1,46 @@
+#[napi]
+pub mod passwords {
+
+ /// The error message returned when a password is not found during retrieval or deletion.
+ #[napi]
+ pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND;
+
+ /// Fetch the stored password from the keychain.
+ /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
+ #[napi]
+ pub async fn get_password(service: String, account: String) -> napi::Result {
+ desktop_core::password::get_password(&service, &account)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ /// Save the password to the keychain. Adds an entry if none exists otherwise updates the
+ /// existing entry.
+ #[napi]
+ pub async fn set_password(
+ service: String,
+ account: String,
+ password: String,
+ ) -> napi::Result<()> {
+ desktop_core::password::set_password(&service, &account, &password)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ /// Delete the stored password from the keychain.
+ /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
+ #[napi]
+ pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
+ desktop_core::password::delete_password(&service, &account)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ /// Checks if the os secure storage is available
+ #[napi]
+ pub async fn is_available() -> napi::Result {
+ desktop_core::password::is_available()
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/powermonitors.rs b/apps/desktop/desktop_native/napi/src/powermonitors.rs
new file mode 100644
index 00000000000..eb673bdbe68
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/powermonitors.rs
@@ -0,0 +1,26 @@
+#[napi]
+pub mod powermonitors {
+ use napi::{
+ threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
+ tokio,
+ };
+
+ #[napi]
+ pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> {
+ let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
+ desktop_core::powermonitor::on_lock(tx)
+ .await
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?;
+ tokio::spawn(async move {
+ while let Some(()) = rx.recv().await {
+ callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
+ }
+ });
+ Ok(())
+ }
+
+ #[napi]
+ pub async fn is_lock_monitor_available() -> napi::Result {
+ Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/processisolations.rs b/apps/desktop/desktop_native/napi/src/processisolations.rs
new file mode 100644
index 00000000000..6ab4a2a645d
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/processisolations.rs
@@ -0,0 +1,23 @@
+#[napi]
+pub mod processisolations {
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn disable_coredumps() -> napi::Result<()> {
+ desktop_core::process_isolation::disable_coredumps()
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn is_core_dumping_disabled() -> napi::Result {
+ desktop_core::process_isolation::is_core_dumping_disabled()
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn isolate_process() -> napi::Result<()> {
+ desktop_core::process_isolation::isolate_process()
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/sshagent.rs b/apps/desktop/desktop_native/napi/src/sshagent.rs
new file mode 100644
index 00000000000..83eec090302
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/sshagent.rs
@@ -0,0 +1,163 @@
+#[napi]
+pub mod sshagent {
+ use std::sync::Arc;
+
+ use napi::{
+ bindgen_prelude::Promise,
+ threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
+ };
+ use tokio::{self, sync::Mutex};
+ use tracing::error;
+
+ #[napi]
+ pub struct SshAgentState {
+ state: desktop_core::ssh_agent::BitwardenDesktopAgent,
+ }
+
+ #[napi(object)]
+ pub struct PrivateKey {
+ pub private_key: String,
+ pub name: String,
+ pub cipher_id: String,
+ }
+
+ #[napi(object)]
+ pub struct SshKey {
+ pub private_key: String,
+ pub public_key: String,
+ pub key_fingerprint: String,
+ }
+
+ #[napi(object)]
+ pub struct SshUIRequest {
+ pub cipher_id: Option,
+ pub is_list: bool,
+ pub process_name: String,
+ pub is_forwarding: bool,
+ pub namespace: Option,
+ }
+
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn serve(
+ callback: ThreadsafeFunction>,
+ ) -> napi::Result {
+ let (auth_request_tx, mut auth_request_rx) =
+ tokio::sync::mpsc::channel::(32);
+ let (auth_response_tx, auth_response_rx) =
+ tokio::sync::broadcast::channel::<(u32, bool)>(32);
+ let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
+ // Wrap callback in Arc so it can be shared across spawned tasks
+ let callback = Arc::new(callback);
+ tokio::spawn(async move {
+ let _ = auth_response_rx;
+
+ while let Some(request) = auth_request_rx.recv().await {
+ let cloned_response_tx_arc = auth_response_tx_arc.clone();
+ let cloned_callback = callback.clone();
+ tokio::spawn(async move {
+ let auth_response_tx_arc = cloned_response_tx_arc;
+ let callback = cloned_callback;
+ // In NAPI v3, obtain the JS callback return as a Promise and await it
+ // in Rust
+ let (tx, rx) = std::sync::mpsc::channel::>();
+ let status = callback.call_with_return_value(
+ Ok(SshUIRequest {
+ cipher_id: request.cipher_id,
+ is_list: request.is_list,
+ process_name: request.process_name,
+ is_forwarding: request.is_forwarding,
+ namespace: request.namespace,
+ }),
+ ThreadsafeFunctionCallMode::Blocking,
+ move |ret: Result, napi::Error>, _env| {
+ if let Ok(p) = ret {
+ let _ = tx.send(p);
+ }
+ Ok(())
+ },
+ );
+
+ let result = if status == napi::Status::Ok {
+ match rx.recv() {
+ Ok(promise) => match promise.await {
+ Ok(v) => v,
+ Err(e) => {
+ error!(error = %e, "UI callback promise rejected");
+ false
+ }
+ },
+ Err(e) => {
+ error!(error = %e, "Failed to receive UI callback promise");
+ false
+ }
+ }
+ } else {
+ error!(error = ?status, "Calling UI callback failed");
+ false
+ };
+
+ let _ = auth_response_tx_arc
+ .lock()
+ .await
+ .send((request.request_id, result))
+ .expect("should be able to send auth response to agent");
+ });
+ }
+ });
+
+ match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server(
+ auth_request_tx,
+ Arc::new(Mutex::new(auth_response_rx)),
+ ) {
+ Ok(state) => Ok(SshAgentState { state }),
+ Err(e) => Err(napi::Error::from_reason(e.to_string())),
+ }
+ }
+
+ #[napi]
+ pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
+ let bitwarden_agent_state = &mut agent_state.state;
+ bitwarden_agent_state.stop();
+ Ok(())
+ }
+
+ #[napi]
+ pub fn is_running(agent_state: &mut SshAgentState) -> bool {
+ let bitwarden_agent_state = agent_state.state.clone();
+ bitwarden_agent_state.is_running()
+ }
+
+ #[napi]
+ pub fn set_keys(
+ agent_state: &mut SshAgentState,
+ new_keys: Vec,
+ ) -> napi::Result<()> {
+ let bitwarden_agent_state = &mut agent_state.state;
+ bitwarden_agent_state
+ .set_keys(
+ new_keys
+ .iter()
+ .map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone()))
+ .collect(),
+ )
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?;
+ Ok(())
+ }
+
+ #[napi]
+ pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
+ let bitwarden_agent_state = &mut agent_state.state;
+ bitwarden_agent_state
+ .lock()
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[napi]
+ pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
+ let bitwarden_agent_state = &mut agent_state.state;
+ bitwarden_agent_state
+ .clear_keys()
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
diff --git a/apps/desktop/desktop_native/napi/src/windows_registry.rs b/apps/desktop/desktop_native/napi/src/windows_registry.rs
new file mode 100644
index 00000000000..e22e2ce46f5
--- /dev/null
+++ b/apps/desktop/desktop_native/napi/src/windows_registry.rs
@@ -0,0 +1,16 @@
+#[napi]
+pub mod windows_registry {
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn create_key(key: String, subkey: String, value: String) -> napi::Result<()> {
+ crate::registry::create_key(&key, &subkey, &value)
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+
+ #[allow(clippy::unused_async)] // FIXME: Remove unused async!
+ #[napi]
+ pub async fn delete_key(key: String, subkey: String) -> napi::Result<()> {
+ crate::registry::delete_key(&key, &subkey)
+ .map_err(|e| napi::Error::from_reason(e.to_string()))
+ }
+}
From 47edae1c21442d8503c3b500192947b238dbd806 Mon Sep 17 00:00:00 2001
From: Jackson Engstrom
Date: Mon, 2 Feb 2026 11:56:53 -0800
Subject: [PATCH 14/21] [PM 21939] Use family icon for free and family orgs
* adds check for a free/family org and updates the icon
---
.../filters/organization-filter.component.html | 2 +-
.../filters/organization-filter.component.ts | 12 ++++++++++++
libs/vault/src/services/vault-filter.service.ts | 10 +++++++++-
3 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html
index 8c73891dc09..9917a1b988c 100644
--- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html
+++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html
@@ -121,7 +121,7 @@
}}"
[attr.aria-pressed]="activeFilter.selectedOrganizationId === organization.id"
>
-
+
{{ organization.name }}
diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts
index 99338ddbb7c..22ad8dc40db 100644
--- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts
+++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts
@@ -5,6 +5,7 @@ import { Component } from "@angular/core";
import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component";
import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
@@ -50,4 +51,15 @@ export class OrganizationFilterComponent extends BaseOrganizationFilterComponent
});
}
}
+
+ getIconString(organization: Organization): string {
+ if (
+ organization?.productTierType === ProductTierType.Free ||
+ organization?.productTierType === ProductTierType.Families
+ ) {
+ return "bwi-family";
+ } else {
+ return "bwi-business";
+ }
+ }
}
diff --git a/libs/vault/src/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts
index b21e140e023..445764827eb 100644
--- a/libs/vault/src/services/vault-filter.service.ts
+++ b/libs/vault/src/services/vault-filter.service.ts
@@ -26,6 +26,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -184,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
const orgNodes: TreeNode[] = [];
orgs.forEach((org) => {
const orgCopy = org as OrganizationFilter;
- orgCopy.icon = "bwi-business";
+ if (
+ org?.productTierType === ProductTierType.Free ||
+ org?.productTierType === ProductTierType.Families
+ ) {
+ orgCopy.icon = "bwi-family";
+ } else {
+ orgCopy.icon = "bwi-business";
+ }
const node = new TreeNode(orgCopy, headNode, orgCopy.name);
orgNodes.push(node);
});
From 5a397fb44e4a7fbd503e39fbea963b353521e21a Mon Sep 17 00:00:00 2001
From: Jonathan Prusik
Date: Mon, 2 Feb 2026 15:01:24 -0500
Subject: [PATCH 15/21] [PM-29236] Refactor of post-submit notification
triggering logic (#18395)
* refactor triggerChangedPasswordNotification logic
* improve triggerChangedPasswordNotification and test coverage to handle scenarios more comprehensively
* restore triggerChangedPasswordNotification logic and move new logic and testing to triggerCipherNotification
* add branching qualification logic for cipher notifications
* add and implement undetermined-cipher-scenario-logic feature flag
* add optional chaining to username comparison of existing login ciphers
* cleanup
* update tests
* prefer explicit length comparisons
---
.../overlay-notifications.background.ts | 17 +
.../notification.background.spec.ts | 1513 ++++++++++++++++-
.../background/notification.background.ts | 457 ++++-
.../overlay-notifications.background.spec.ts | 128 ++
.../overlay-notifications.background.ts | 76 +-
.../abstractions/notification-bar.ts | 4 +
libs/common/src/enums/feature-flag.enum.ts | 2 +
7 files changed, 2153 insertions(+), 44 deletions(-)
diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts
index a70ffe25310..ae5026c9566 100644
--- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts
+++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts
@@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTask } from "@bitwarden/common/vault/tasks";
import AutofillPageDetails from "../../models/autofill-page-details";
+import { NotificationTypes } from "../../notification/abstractions/notification-bar";
export type NotificationTypeData = {
isVaultLocked?: boolean;
@@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = {
uri: ModifyLoginCipherFormData["uri"];
};
+/**
+ * Distinguished from `NotificationTypes` in that this represents the
+ * pre-resolved notification scenario, vs the notification component
+ * (e.g. "Add" and "Change" will be removed
+ * post-`useUndeterminedCipherScenarioTriggeringLogic` migration)
+ */
+export const NotificationScenarios = {
+ ...NotificationTypes,
+ /** represents scenarios handling saving new and updated ciphers after form submit */
+ Cipher: "cipher",
+} as const;
+
+export type NotificationScenario =
+ (typeof NotificationScenarios)[keyof typeof NotificationScenarios];
+
export type WebsiteOriginsWithFields = Map>;
export type ActiveFormSubmissionRequests = Set;
+/** This type represents an expectation of nullish values being represented as empty strings */
export type ModifyLoginCipherFormData = {
uri: string;
username: string;
diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts
index a927c75dba0..0be6e5c0ac1 100644
--- a/apps/browser/src/autofill/background/notification.background.spec.ts
+++ b/apps/browser/src/autofill/background/notification.background.spec.ts
@@ -67,8 +67,10 @@ describe("NotificationBackground", () => {
});
const folderService = mock();
const enableChangedPasswordPromptMock$ = new BehaviorSubject(true);
+ const enableAddedLoginPromptMock$ = new BehaviorSubject(true);
const userNotificationSettingsService = mock();
userNotificationSettingsService.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$;
+ userNotificationSettingsService.enableAddedLoginPrompt$ = enableAddedLoginPromptMock$;
const domainSettingsService = mock();
const environmentService = mock();
@@ -90,7 +92,9 @@ describe("NotificationBackground", () => {
});
beforeEach(() => {
- activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked);
+ activeAccountStatusMock$ = new BehaviorSubject(
+ AuthenticationStatus.Locked as AuthenticationStatus,
+ );
authService = mock();
authService.activeAccountStatus$ = activeAccountStatusMock$;
accountService.activeAccount$ = activeAccountSubject;
@@ -290,7 +294,7 @@ describe("NotificationBackground", () => {
username: "test",
password: "password",
uri: "https://example.com",
- newPassword: null,
+ newPassword: "",
};
beforeEach(() => {
tab = createChromeTabMock();
@@ -323,7 +327,7 @@ describe("NotificationBackground", () => {
...mockModifyLoginCipherFormData,
uri: "",
};
- activeAccountStatusMock$.next(AuthenticationStatus.Locked);
+ activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
await notificationBackground.triggerAddLoginNotification(data, tab);
@@ -389,14 +393,14 @@ describe("NotificationBackground", () => {
password: data.password,
},
sender.tab,
- true,
+ true, // will yield an unlock followed by a new password notification
);
});
it("adds the login to the queue if the user has an unlocked account and the login is new", async () => {
const data: ModifyLoginCipherFormData = {
...mockModifyLoginCipherFormData,
- username: null,
+ username: "",
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
@@ -426,8 +430,8 @@ describe("NotificationBackground", () => {
let pushChangePasswordToQueueSpy: jest.SpyInstance;
let getAllDecryptedForUrlSpy: jest.SpyInstance;
const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = {
- username: null,
- uri: null,
+ username: "",
+ uri: "",
password: "currentPassword",
newPassword: "newPassword",
};
@@ -527,7 +531,7 @@ describe("NotificationBackground", () => {
...mockModifyLoginCipherFormData,
uri: "https://example.com",
password: "newPasswordUpdatedElsewhere",
- newPassword: null,
+ newPassword: "",
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
@@ -589,7 +593,7 @@ describe("NotificationBackground", () => {
"example.com",
data?.newPassword,
sender.tab,
- true,
+ true, // will yield an unlock followed by an update password notification
);
});
@@ -597,8 +601,8 @@ describe("NotificationBackground", () => {
const data: ModifyLoginCipherFormData = {
...mockModifyLoginCipherFormData,
uri: "https://example.com",
- password: null,
- newPassword: null,
+ password: "",
+ newPassword: "",
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
@@ -637,7 +641,7 @@ describe("NotificationBackground", () => {
const data: ModifyLoginCipherFormData = {
...mockModifyLoginCipherFormData,
uri: "https://example.com",
- password: null,
+ password: "",
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
@@ -665,7 +669,7 @@ describe("NotificationBackground", () => {
const data: ModifyLoginCipherFormData = {
...mockModifyLoginCipherFormData,
uri: "https://example.com",
- password: null,
+ password: "",
};
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
@@ -686,6 +690,1489 @@ describe("NotificationBackground", () => {
});
});
+ describe("triggerCipherNotification message handler", () => {
+ let tab: chrome.tabs.Tab;
+ let sender: chrome.runtime.MessageSender;
+ let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
+ let getEnableAddedLoginPromptSpy: jest.SpyInstance;
+ let pushChangePasswordToQueueSpy: jest.SpyInstance;
+ let pushAddLoginToQueueSpy: jest.SpyInstance;
+ let getAllDecryptedForUrlSpy: jest.SpyInstance;
+ const mockFormattedURI = "archive.org";
+ const mockFormURI = "https://www.archive.org";
+ const expectSkippedCheckingNotification = () => {
+ expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled();
+ expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
+ expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
+ };
+
+ beforeEach(() => {
+ tab = createChromeTabMock();
+ sender = mock({ tab });
+ getEnableAddedLoginPromptSpy = jest.spyOn(
+ notificationBackground as any,
+ "getEnableAddedLoginPrompt",
+ );
+ getEnableChangedPasswordPromptSpy = jest.spyOn(
+ notificationBackground as any,
+ "getEnableChangedPasswordPrompt",
+ );
+
+ pushChangePasswordToQueueSpy = jest.spyOn(
+ notificationBackground as any,
+ "pushChangePasswordToQueue",
+ );
+ pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue");
+ getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl");
+ });
+
+ afterEach(() => {
+ getEnableAddedLoginPromptSpy.mockRestore();
+ getEnableChangedPasswordPromptSpy.mockRestore();
+ pushChangePasswordToQueueSpy.mockRestore();
+ pushAddLoginToQueueSpy.mockRestore();
+ getAllDecryptedForUrlSpy.mockRestore();
+ });
+
+ it("skips checking if a notification should trigger if no fields were filled", async () => {
+ const formEntryData: ModifyLoginCipherFormData = {
+ newPassword: "",
+ password: "",
+ uri: mockFormURI,
+ username: "",
+ };
+
+ const storedCiphersForURL = [
+ mock({
+ id: "cipher-id-1",
+ login: { password: "I<3VogonPoetry", username: "ADent" },
+ }),
+ ];
+
+ activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
+ getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL);
+
+ await notificationBackground.triggerCipherNotification(formEntryData, tab);
+
+ expectSkippedCheckingNotification();
+ });
+
+ it("skips checking if a notification should trigger if the passed url is not valid", async () => {
+ const formEntryData: ModifyLoginCipherFormData = {
+ newPassword: "Bab3lPhs5h",
+ password: "I<3VogonPoetry",
+ uri: "",
+ username: "ADent",
+ };
+
+ const storedCiphersForURL = [
+ mock({
+ id: "cipher-id-1",
+ login: { password: "I<3VogonPoetry", username: "ADent" },
+ }),
+ ];
+
+ activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
+ getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL);
+
+ await notificationBackground.triggerCipherNotification(formEntryData, tab);
+
+ expectSkippedCheckingNotification();
+ });
+
+ it("skips checking if a notification should trigger if the user has disabled both the new login and update password notification", async () => {
+ const formEntryData: ModifyLoginCipherFormData = {
+ newPassword: "Bab3lPhs5h",
+ password: "I<3VogonPoetry",
+ uri: mockFormURI,
+ username: "ADent",
+ };
+
+ const storedCiphersForURL = [
+ mock |