1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-30 16:23:53 +00:00

Merge branch 'main' into billing/pm-29600/update-tax-client

This commit is contained in:
Stephon Brown
2026-01-27 18:41:09 -05:00
59 changed files with 1646 additions and 726 deletions

2
.github/CODEOWNERS vendored
View File

@@ -8,7 +8,7 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/autofill_provider @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates

View File

@@ -187,6 +187,7 @@
"semver",
"serde",
"serde_json",
"serde_with",
"simplelog",
"style-loader",
"sysinfo",

View File

@@ -1,7 +1,6 @@
{
"devFlags": {},
"flags": {
"accountSwitching": false,
"sdk": true
}
}

View File

@@ -4,8 +4,5 @@
"base": "https://localhost:8080"
},
"skipWelcomeOnInstall": true
},
"flags": {
"accountSwitching": true
}
}

View File

@@ -1,5 +0,0 @@
{
"flags": {
"accountSwitching": true
}
}

View File

@@ -7,13 +7,16 @@
</popup-header>
<ng-container *ngIf="availableAccounts$ | async as availableAccounts">
<bit-section [disableMargin]="!enableAccountSwitching">
<bit-section [disableMargin]="!(enableAccountSwitching$ | async)">
<ng-container *ngFor="let availableAccount of availableAccounts; first as isFirst">
<div *ngIf="availableAccount.isActive" [ngClass]="{ 'tw-mb-6': enableAccountSwitching }">
<div
*ngIf="availableAccount.isActive"
[ngClass]="{ 'tw-mb-6': enableAccountSwitching$ | async }"
>
<auth-account [account]="availableAccount" (loading)="loading = $event"></auth-account>
</div>
<ng-container *ngIf="enableAccountSwitching">
<ng-container *ngIf="enableAccountSwitching$ | async">
<bit-section-header *ngIf="isFirst">
<h2 bitTypography="h6">{{ "availableAccounts" | i18n }}</h2>
</bit-section-header>

View File

@@ -1,7 +1,7 @@
import { CommonModule, Location } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs";
import { Observable, Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LockService, LogoutService } from "@bitwarden/auth/common";
@@ -24,7 +24,6 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { enableAccountSwitching } from "../../../platform/flags";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -59,7 +58,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
loading = false;
activeUserCanLock = false;
enableAccountSwitching = true;
enableAccountSwitching$: Observable<boolean>;
constructor(
private accountSwitcherService: AccountSwitcherService,
@@ -72,7 +71,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
private authService: AuthService,
private lockService: LockService,
private logoutService: LogoutService,
) {}
) {
this.enableAccountSwitching$ = this.accountSwitcherService.accountSwitchingEnabled$();
}
get accountLimit() {
return this.accountSwitcherService.ACCOUNT_LIMIT;
@@ -97,19 +98,21 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
switchMap((accounts) => {
// If account switching is disabled, don't show the lock all button
// as only one account should be shown.
if (!enableAccountSwitching()) {
return of(false);
}
return this.accountSwitcherService.accountSwitchingEnabled$().pipe(
switchMap((enabled) => {
if (!enabled) {
return of(false);
}
// When there are an inactive accounts provide the option to lock all accounts
// Note: "Add account" is counted as an inactive account, so check for more than one account
return of(accounts.length > 1);
// When there are inactive accounts provide the option to lock all accounts
// Note: "Add account" is counted as an inactive account, so check for more than one account
return of(accounts.length > 1);
}),
);
}),
);
async ngOnInit() {
this.enableAccountSwitching = enableAccountSwitching();
const availableVaultTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);

View File

@@ -9,6 +9,7 @@ import {
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
Environment,
EnvironmentService,
@@ -37,6 +38,7 @@ describe("AccountSwitcherService", () => {
const environmentService = mock<EnvironmentService>();
const logService = mock<LogService>();
const authService = mock<AuthService>();
const configService = mock<ConfigService>();
let accountSwitcherService: AccountSwitcherService;
@@ -60,6 +62,7 @@ describe("AccountSwitcherService", () => {
messagingService,
environmentService,
logService,
configService,
authService,
);
});

View File

@@ -7,6 +7,7 @@ import {
filter,
firstValueFrom,
map,
of,
switchMap,
throwError,
timeout,
@@ -17,11 +18,14 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event";
export type AvailableAccount = {
@@ -52,6 +56,7 @@ export class AccountSwitcherService {
private messagingService: MessagingService,
private environmentService: EnvironmentService,
private logService: LogService,
private configService: ConfigService,
authService: AuthService,
) {
this.availableAccounts$ = combineLatest([
@@ -123,6 +128,19 @@ export class AccountSwitcherService {
);
}
/*
* PM-5594: This was a compile-time flag (default true) which made an exception for Safari in platform/flags.
* The truthiness of AccountSwitching has been enshrined at this point, so those compile-time flags have been removed
* in favor of this method to allow easier access to the config service for controlling Safari. Unwinding the Safari
* flag should be more straightforward from this consolidation.
*/
accountSwitchingEnabled$(): Observable<boolean> {
if (BrowserApi.isSafariApi) {
return this.configService.getFeatureFlag$(FeatureFlag.SafariAccountSwitching);
}
return of(true);
}
get specialAccountAddId() {
return this.SPECIAL_ADD_ACCOUNT_ID;
}

View File

@@ -8,7 +8,9 @@
<div class="tw-bg-background-alt">
<p>
{{
accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | i18n)
(accountSwitcherEnabled$ | async)
? ("excludedDomainsDescAlt" | i18n)
: ("excludedDomainsDesc" | i18n)
}}
</p>
<bit-section *ngIf="!isLoading">

View File

@@ -15,7 +15,7 @@ import {
FormArray,
} from "@angular/forms";
import { RouterModule } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { Observable, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
@@ -35,7 +35,7 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { enableAccountSwitching } from "../../../platform/flags";
import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
@@ -74,7 +74,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy {
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
new QueryList();
accountSwitcherEnabled = false;
readonly accountSwitcherEnabled$: Observable<boolean> =
this.accountSwitcherService.accountSwitchingEnabled$();
dataIsPristine = true;
isLoading = false;
excludedDomainsState: string[] = [];
@@ -95,9 +96,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy {
private toastService: ToastService,
private formBuilder: FormBuilder,
private popupRouterCacheService: PopupRouterCacheService,
) {
this.accountSwitcherEnabled = enableAccountSwitching();
}
private accountSwitcherService: AccountSwitcherService,
) {}
get domainForms() {
return this.domainListForm.get("domains") as FormArray;

View File

@@ -8,12 +8,8 @@ import {
import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-environment";
import { BrowserApi } from "./browser/browser-api";
// required to avoid linting errors when there are no flags
export type Flags = {
accountSwitching?: boolean;
} & SharedFlags;
export type Flags = SharedFlags;
// required to avoid linting errors when there are no flags
export type DevFlags = {
@@ -31,14 +27,3 @@ export function devFlagEnabled(flag: keyof DevFlags) {
export function devFlagValue(flag: keyof DevFlags) {
return baseDevFlagValue(flag);
}
/** Helper method to sync flag specifically for account switching, which as platform-based values.
* If this pattern needs to be repeated, it's better handled by increasing complexity of webpack configurations
* Not by expanding these flag getters.
*/
export function enableAccountSwitching(): boolean {
if (BrowserApi.isSafariApi) {
return false;
}
return flagEnabled("accountSwitching");
}

View File

@@ -5,8 +5,7 @@
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
showAutofillButton
isAutofillList
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
[groupByType]="groupByType()"
></app-vault-list-items-container>

View File

@@ -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, startWith } from "rxjs";
import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@@ -42,12 +42,6 @@ export class AutofillVaultListItemsComponent {
*/
protected showRefresh: boolean = BrowserPopupUtils.inSidebar(window);
/** Flag indicating whether the login item should automatically autofill when clicked */
protected clickItemsToAutofillVaultView$: Observable<boolean> =
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)),
);

View File

@@ -8,14 +8,14 @@
></button>
<bit-menu #moreOptions>
@if (!decryptionFailure) {
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
<ng-container *ngIf="canAutofill && showAutofill()">
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
</ng-container>
</ng-container>
<ng-container *ngIf="showViewOption">
<ng-container>
<button type="button" bitMenuItem (click)="onView()">
{{ "view" | i18n }}
</button>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { booleanAttribute, Component, input, 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,22 +76,10 @@ export class ItemMoreOptionsComponent {
}
/**
* 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
* Flag to show the autofill menu options. Used for items that are
* already in the autofill list suggestion.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
hideAutofillOptions = false;
readonly showAutofill = input(false, { transform: booleanAttribute });
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;

View File

@@ -90,11 +90,11 @@
</ng-container>
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
<bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item">
<button
bit-item-content
type="button"
(click)="primaryActionOnSelect(cipher)"
(click)="onCipherSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="
cipherItemTitleKey()(cipher)
@@ -125,19 +125,14 @@
</button>
<ng-container slot="end">
<bit-item-action *ngIf="!hideAutofillButton()">
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
<bit-item-action *ngIf="isAutofillList()">
<span
class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100"
>
{{ "fill" | i18n }}
</button>
</span>
</bit-item-action>
<bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
<bit-item-action *ngIf="!isAutofillList() && CipherViewLikeUtils.canLaunch(cipher)">
<button
type="button"
bitIconButton="bwi-external-link"
@@ -149,8 +144,7 @@
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[hideAutofillOptions]="hideAutofillMenuOptions()"
[showViewOption]="primaryActionAutofill()"
[showAutofill]="!isAutofillList()"
></app-item-more-options>
</ng-container>
</bit-item>

View File

@@ -136,24 +136,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
ciphers = input<PopupCipherViewLike[]>([]);
readonly ciphers = input<PopupCipherViewLike[]>([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
groupByType = input<boolean | undefined>(false);
readonly groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherGroups = computed<
readonly cipherGroups = computed<
{
subHeaderKey?: string;
ciphers: PopupCipherViewLike[];
@@ -195,9 +189,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
title = input<string | undefined>(undefined);
readonly title = input<string | undefined>(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -205,24 +197,20 @@ 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.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
description = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showRefresh = input(false, { transform: booleanAttribute });
readonly showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
@@ -235,23 +223,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Flag indicating that the current tab location is blocked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherItemTitleKey = computed(() => {
readonly cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
const key =
this.primaryActionAutofill() && !this.currentURIIsBlocked()
? "autofillTitle"
: "viewItemTitle";
const key = !this.currentUriIsBlocked() ? "autofillTitle" : "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
};
});
@@ -259,47 +240,25 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Option to show the autofill button for each item.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showAutofillButton = input(false, { transform: booleanAttribute });
readonly isAutofillList = input(false, { transform: booleanAttribute });
/**
* Flag indicating whether the suggested cipher item autofill button should be shown or not
* Computed property whether the cipher select action should perform autofill
*/
// 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(),
readonly shouldAutofillOnSelect = computed(
() => this.isAutofillList() && !this.currentUriIsBlocked(),
);
/**
* 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)
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableSectionMargin = input(false, { transform: booleanAttribute });
readonly disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableDescriptionMargin = input(false, { transform: booleanAttribute });
readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
@@ -313,9 +272,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
protected readonly autofillShortcutTooltip = signal<string | undefined>(undefined);
constructor(
private i18nService: I18nService,
@@ -340,10 +297,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
}
}
primaryActionOnSelect(cipher: PopupCipherViewLike) {
return this.primaryActionAutofill() && !this.currentURIIsBlocked()
? this.doAutofill(cipher)
: this.onViewCipher(cipher);
onCipherSelect(cipher: PopupCipherViewLike) {
return this.shouldAutofillOnSelect() ? this.doAutofill(cipher) : this.onViewCipher(cipher);
}
/**

View File

@@ -50,16 +50,10 @@
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</bit-label>
</bit-form-control>
<bit-form-control>
<bit-form-control disableMargin>
<input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" />
<bit-label>{{ "showQuickCopyActions" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control disableMargin>
<input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" />
<bit-label>
{{ "clickToAutofill" | i18n }}
</bit-label>
</bit-form-control>
</bit-card>
</form>
</popup-page>

View File

@@ -59,14 +59,12 @@ describe("AppearanceV2Component", () => {
const enableRoutingAnimation$ = new BehaviorSubject<boolean>(true);
const enableCompactMode$ = new BehaviorSubject<boolean>(false);
const showQuickCopyActions$ = new BehaviorSubject<boolean>(false);
const clickItemsToAutofillVaultView$ = new BehaviorSubject<boolean>(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<PopupSizeService> = {
width$: new BehaviorSubject("default"),
@@ -113,10 +111,7 @@ describe("AppearanceV2Component", () => {
},
{
provide: VaultSettingsService,
useValue: {
clickItemsToAutofillVaultView$,
setClickItemsToAutofillVaultView,
},
useValue: mock<VaultSettingsService>(),
},
],
})
@@ -147,7 +142,6 @@ describe("AppearanceV2Component", () => {
enableCompactMode: false,
showQuickCopyActions: false,
width: "default",
clickItemsToAutofillVaultView: false,
});
});
@@ -193,11 +187,5 @@ 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);
});
});
});

View File

@@ -66,7 +66,6 @@ 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. */
@@ -112,9 +111,6 @@ 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({
@@ -125,7 +121,6 @@ export class AppearanceV2Component implements OnInit {
enableCompactMode,
showQuickCopyActions,
width,
clickItemsToAutofillVaultView,
});
this.formLoading = false;
@@ -171,16 +166,6 @@ 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) {

View File

@@ -47,6 +47,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.18"
@@ -324,6 +333,23 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "autofill_provider"
version = "0.0.0"
dependencies = [
"base64",
"desktop_core",
"futures",
"serde",
"serde_json",
"serde_with",
"tokio",
"tracing",
"tracing-oslog",
"tracing-subscriber",
"uniffi",
]
[[package]]
name = "autotype"
version = "0.0.0"
@@ -603,6 +629,18 @@ dependencies = [
"windows",
]
[[package]]
name = "chrono"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -799,6 +837,41 @@ dependencies = [
"syn",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -810,6 +883,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "desktop_core"
version = "0.0.0"
@@ -988,6 +1071,12 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1410,6 +1499,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.3"
@@ -1425,7 +1520,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
"hashbrown 0.15.3",
]
[[package]]
@@ -1476,6 +1571,30 @@ dependencies = [
"windows",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.0.0"
@@ -1562,6 +1681,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.0.3"
@@ -1583,6 +1708,17 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.9.0"
@@ -1590,7 +1726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.3",
"serde",
]
[[package]]
@@ -1744,21 +1881,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "macos_provider"
version = "0.0.0"
dependencies = [
"desktop_core",
"futures",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-oslog",
"tracing-subscriber",
"uniffi",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2017,6 +2139,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2315,7 +2443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
"indexmap",
"indexmap 2.9.0",
]
[[package]]
@@ -2441,6 +2569,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -2622,6 +2756,26 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
@@ -2766,6 +2920,30 @@ dependencies = [
"sdd",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2911,6 +3089,38 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars 0.9.0",
"schemars 1.2.0",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serial_test"
version = "3.3.1"
@@ -3231,6 +3441,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -3298,7 +3539,7 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime 0.7.0",
@@ -3328,7 +3569,7 @@ version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"indexmap 2.9.0",
"toml_datetime 0.6.9",
"winnow",
]

View File

@@ -1,11 +1,11 @@
[workspace]
resolver = "2"
members = [
"autofill_provider",
"autotype",
"bitwarden_chromium_import_helper",
"chromium_importer",
"core",
"macos_provider",
"napi",
"process_isolation",
"proxy",
@@ -58,6 +58,7 @@ security-framework = "=3.5.1"
security-framework-sys = "=2.15.0"
serde = "=1.0.209"
serde_json = "=1.0.127"
serde_with = "=3.14.1"
sha2 = "=0.10.9"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }

View File

@@ -1,12 +1,12 @@
[package]
name = "macos_provider"
name = "autofill_provider"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[lib]
crate-type = ["staticlib", "cdylib"]
crate-type = ["lib", "staticlib", "cdylib"]
bench = false
[[bin]]
@@ -14,17 +14,19 @@ name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"
[dependencies]
uniffi = { workspace = true, features = ["cli"] }
[target.'cfg(target_os = "macos")'.dependencies]
base64 = { workspace = true }
desktop_core = { path = "../core" }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true, features = ["base64"] }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
tracing-oslog = "=0.3.0"
tracing-subscriber = { workspace = true }
uniffi = { workspace = true, features = ["cli"] }
[build-dependencies]
uniffi = { workspace = true, features = ["build"] }

View File

@@ -1,3 +1,7 @@
# Autofill Provider
A library for native autofill providers to interact with a host Bitwarden desktop app.
# Explainer: Mac OS Native Passkey Provider
This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context.

View File

@@ -2,8 +2,12 @@
cd "$(dirname "$0")"
rm -r BitwardenMacosProviderFFI.xcframework
rm -r tmp
if [ -d "BitwardenMacosProviderFFI.xcframework" ]; then
rm -r "BitwardenMacosProviderFFI.xcframework"
fi
if [ -d "tmp" ]; then
rm -r "tmp"
fi
mkdir -p ./tmp/target/universal-darwin/release/
@@ -11,17 +15,17 @@ mkdir -p ./tmp/target/universal-darwin/release/
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
cargo build --package macos_provider --target aarch64-apple-darwin --release
cargo build --package macos_provider --target x86_64-apple-darwin --release
cargo build --package autofill_provider --target aarch64-apple-darwin --release
cargo build --package autofill_provider --target x86_64-apple-darwin --release
# Create universal libraries
lipo -create ../target/aarch64-apple-darwin/release/libmacos_provider.a \
../target/x86_64-apple-darwin/release/libmacos_provider.a \
-output ./tmp/target/universal-darwin/release/libmacos_provider.a
lipo -create ../target/aarch64-apple-darwin/release/libautofill_provider.a \
../target/x86_64-apple-darwin/release/libautofill_provider.a \
-output ./tmp/target/universal-darwin/release/libautofill_provider.a
# Generate swift bindings
cargo run --bin uniffi-bindgen --features uniffi/cli generate \
../target/aarch64-apple-darwin/release/libmacos_provider.dylib \
../target/aarch64-apple-darwin/release/libautofill_provider.dylib \
--library \
--language swift \
--no-format \
@@ -38,7 +42,7 @@ cat ./tmp/bindings/*.modulemap > ./tmp/Headers/module.modulemap
# Build xcframework
xcodebuild -create-xcframework \
-library ./tmp/target/universal-darwin/release/libmacos_provider.a \
-library ./tmp/target/universal-darwin/release/libautofill_provider.a \
-headers ./tmp/Headers \
-output ./BitwardenMacosProviderFFI.xcframework

View File

@@ -0,0 +1,184 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
#[cfg(not(target_os = "macos"))]
use crate::TimedCallback;
use crate::{BitwardenError, Callback, Position, UserVerification};
/// Request to assert a credential.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
/// Relying Party ID for the request.
pub rp_id: String,
/// SHA-256 hash of the `clientDataJSON` for the assertion request.
pub client_data_hash: Vec<u8>,
/// User verification preference.
pub user_verification: UserVerification,
/// List of allowed credential IDs.
pub allowed_credentials: Vec<Vec<u8>>,
/// Coordinates of the center of the WebAuthn client's window, relative to
/// the top-left point on the screen.
/// # Operating System Differences
///
/// ## macOS
/// Note that macOS APIs gives points relative to the bottom-left point on the
/// screen by default, so the y-coordinate will be flipped.
///
/// ## Windows
/// On Windows, this must be logical pixels, not physical pixels.
pub window_xy: Position,
/// Byte string representing the native OS window handle for the WebAuthn client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[cfg(not(target_os = "macos"))]
pub client_window_handle: Vec<u8>,
/// Native context required for callbacks to the OS. Format differs on the OS.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a base64-string representing the following data:
/// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)`
#[cfg(not(target_os = "macos"))]
pub context: String,
// TODO(PM-30510): Implement support for extensions
// pub extension_input: Vec<u8>,
}
/// Request to assert a credential without user interaction.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
/// Relying Party ID.
pub rp_id: String,
/// The allowed credential ID for the request.
pub credential_id: Vec<u8>,
/// The user name for the credential that was previously given to the OS.
#[cfg(target_os = "macos")]
pub user_name: String,
/// The user ID for the credential that was previously given to the OS.
#[cfg(target_os = "macos")]
pub user_handle: Vec<u8>,
/// The app-specific local identifier for the credential, in our case, the
/// cipher ID.
#[cfg(target_os = "macos")]
pub record_identifier: Option<String>,
/// SHA-256 hash of the `clientDataJSON` for the assertion request.
pub client_data_hash: Vec<u8>,
/// User verification preference.
pub user_verification: UserVerification,
/// Coordinates of the center of the WebAuthn client's window, relative to
/// the top-left point on the screen.
/// # Operating System Differences
///
/// ## macOS
/// Note that macOS APIs gives points relative to the bottom-left point on the
/// screen by default, so the y-coordinate will be flipped.
///
/// ## Windows
/// On Windows, this must be logical pixels, not physical pixels.
pub window_xy: Position,
/// Byte string representing the native OS window handle for the WebAuthn client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[cfg(not(target_os = "macos"))]
pub client_window_handle: Vec<u8>,
/// Native context required for callbacks to the OS. Format differs on the OS.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is `request transaction id () || SHA-256(pluginOperationRequest)`.
#[cfg(not(target_os = "macos"))]
pub context: String,
}
/// Response for a passkey assertion request.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
/// Relying Party ID.
pub rp_id: String,
/// The user ID for the credential that was previously given to the OS.
pub user_handle: Vec<u8>,
/// The signature for the WebAuthn attestation response.
pub signature: Vec<u8>,
/// SHA-256 hash of the `clientDataJSON` used in the assertion.
pub client_data_hash: Vec<u8>,
/// The WebAuthn authenticator data structure.
pub authenticator_data: Vec<u8>,
/// The ID for the attested credential.
pub credential_id: Vec<u8>,
}
/// Callback to process a response to passkey assertion request.
#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))]
pub trait PreparePasskeyAssertionCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, credential: PasskeyAssertionResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
}
}
#[cfg(not(target_os = "macos"))]
impl PreparePasskeyAssertionCallback for TimedCallback<PasskeyAssertionResponse> {
fn on_complete(&self, credential: PasskeyAssertionResponse) {
self.send(Ok(credential));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}

View File

@@ -0,0 +1,754 @@
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
mod assertion;
mod lock_status;
mod registration;
mod window_handle_query;
#[cfg(target_os = "macos")]
use std::sync::Once;
use std::{
collections::HashMap,
error::Error,
fmt::Display,
path::PathBuf,
sync::{
atomic::AtomicU32,
mpsc::{self, Receiver, RecvTimeoutError, Sender},
Arc, Mutex,
},
time::{Duration, Instant},
};
pub use assertion::{
PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
};
use futures::FutureExt;
pub use lock_status::LockStatusResponse;
pub use registration::{
PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{error, info};
#[cfg(target_os = "macos")]
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
layer::SubscriberExt,
util::SubscriberInitExt,
};
pub use window_handle_query::WindowHandleQueryResponse;
use crate::{
lock_status::{GetLockStatusCallback, LockStatusRequest},
window_handle_query::{GetWindowHandleQueryCallback, WindowHandleQueryRequest},
};
#[cfg(target_os = "macos")]
uniffi::setup_scaffolding!();
#[cfg(target_os = "macos")]
static INIT: Once = Once::new();
/// User verification preference for WebAuthn requests.
#[cfg_attr(target_os = "macos", derive(uniffi::Enum))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {
Preferred,
Required,
Discouraged,
}
/// Coordinates representing a point on the screen.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[cfg_attr(target_os = "macos", derive(uniffi::Error))]
#[derive(Debug, Serialize, Deserialize)]
pub enum BitwardenError {
Internal(String),
Disconnected,
}
impl Display for BitwardenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Internal(msg) => write!(f, "Internal error occurred: {msg}"),
Self::Disconnected => {
write!(f, "Client is disconnected from autofill IPC service")
}
}
}
}
impl Error for BitwardenError {}
// These methods are named differently than the actual Uniffi traits (without
// the `on_` prefix) to avoid ambiguous trait implementations in the generated
// code.
trait Callback: Send + Sync {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
fn error(&self, error: BitwardenError);
}
/// Store the connection status between the credential provider extension
/// and the desktop application's IPC server.
#[cfg_attr(target_os = "macos", derive(uniffi::Enum))]
#[derive(Debug)]
pub enum ConnectionStatus {
Connected,
Disconnected,
}
/// A client to send and receive messages to the autofill service on the desktop
/// client.
///
/// # Usage
///
/// In order to accommodate desktop app startup delays and non-blocking
/// requirements for native providers, this initialization of the client is
/// non-blocking. When calling [`AutofillProviderClient::connect()`], the
/// connection is not established immediately, but may be established later in
/// the background or may fail to be established.
///
/// Before calling [`AutofillProviderClient::connect()`], first check whether
/// the desktop app is running with [`AutofillProviderClient::is_available`],
/// and attempt to start it if it is not running. Then, attempt to connect, retrying as necessary.
/// Before calling any other methods, check the connection status using
/// [`AutofillProviderClient::get_connection_status()`].
///
/// # Examples
///
/// ```no_run
/// use std::{sync::Arc, time::Duration};
///
/// use autofill_provider::{AutofillProviderClient, ConnectionStatus, TimedCallback};
///
/// fn establish_connection() -> Option<AutofillProviderClient> {
/// if !AutofillProviderClient::is_available() {
/// // Start application
/// }
/// let max_attempts = 20;
/// let delay = Duration::from_millis(300);
///
/// for attempt in 0..=max_attempts {
/// let client = AutofillProviderClient::connect();
/// if attempt != 0 {
/// // Use whatever sleep method is appropriate
/// std::thread::sleep(delay + Duration::from_millis(100 * attempt));
/// }
/// if let ConnectionStatus::Connected = client.get_connection_status() {
/// return Some(client);
/// }
/// };
/// None
/// }
///
/// if let Some(client) = establish_connection() {
/// // use client here
/// }
/// ```
#[cfg_attr(target_os = "macos", derive(uniffi::Object))]
pub struct AutofillProviderClient {
to_server_send: tokio::sync::mpsc::Sender<String>,
// We need to keep track of the callbacks so we can call them when we receive a response
response_callbacks_counter: AtomicU32,
#[allow(clippy::type_complexity)]
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
// Flag to track connection status - atomic for thread safety without locks
connection_status: Arc<std::sync::atomic::AtomicBool>,
}
/// Store native desktop status information to use for IPC communication
/// between the application and the credential provider.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeStatus {
key: String,
value: String,
}
// In our callback management, 0 is a reserved sequence number indicating that a message does not
// have a callback.
const NO_CALLBACK_INDICATOR: u32 = 0;
#[cfg(not(test))]
static IPC_PATH: &str = "af";
#[cfg(test)]
static IPC_PATH: &str = "af-test";
// These methods are not currently needed in macOS and/or cannot be exported via FFI
impl AutofillProviderClient {
/// Whether the client is immediately available for connection.
pub fn is_available() -> bool {
desktop_core::ipc::path(IPC_PATH).exists()
}
/// Request the desktop client's lock status.
pub fn get_lock_status(&self, callback: Arc<dyn GetLockStatusCallback>) {
self.send_message(LockStatusRequest {}, Some(Box::new(callback)));
}
/// Requests details about the desktop client's native window.
pub fn get_window_handle(&self, callback: Arc<dyn GetWindowHandleQueryCallback>) {
self.send_message(
WindowHandleQueryRequest::default(),
Some(Box::new(callback)),
);
}
fn connect_to_path(path: PathBuf) -> Self {
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32);
let client = AutofillProviderClient {
to_server_send,
response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
* "no callback" scenarios */
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let queue = client.response_callbacks_queue.clone();
let connection_status = client.connection_status.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Can't create runtime");
rt.spawn(
desktop_core::ipc::client::connect(path.clone(), from_server_send, to_server_recv)
.map(move |r| {
if let Err(err) = r {
tracing::error!(
?path,
"Failed to connect to autofill IPC server: {err}"
);
}
}),
);
rt.block_on(async move {
while let Some(message) = from_server_recv.recv().await {
match serde_json::from_str::<SerializedMessage>(&message) {
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
info!("Connected to server");
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
info!("Disconnected from server");
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Message {
sequence_number,
value,
}) => match queue.lock().expect("not poisoned").remove(&sequence_number) {
Some((cb, request_start_time)) => {
info!(
"Time to process request: {:?}",
request_start_time.elapsed()
);
match value {
Ok(value) => {
if let Err(e) = cb.complete(value) {
error!(error = %e, "Error deserializing message");
}
}
Err(e) => {
error!(error = ?e, "Error processing message");
cb.error(e);
}
}
}
None => {
error!(sequence_number, "No callback found for sequence number");
}
},
Err(e) => {
error!(error = %e, %message, "Error deserializing message");
}
};
}
});
});
client
}
}
#[cfg_attr(target_os = "macos", uniffi::export)]
impl AutofillProviderClient {
/// Asynchronously initiates a connection to the autofill service on the desktop client.
///
/// See documentation at the top-level of [this struct][AutofillProviderClient] for usage
/// information.
#[cfg_attr(target_os = "macos", uniffi::constructor)]
pub fn connect() -> Self {
tracing::trace!("Autofill provider attempting to connect to Electron IPC...");
let path = desktop_core::ipc::path(IPC_PATH);
Self::connect_to_path(path)
}
/// Send a one-way key-value message to the desktop client.
pub fn send_native_status(&self, key: String, value: String) {
let status = NativeStatus { key, value };
self.send_message(status, None);
}
/// Send a request to create a new passkey to the desktop client.
pub fn prepare_passkey_registration(
&self,
request: PasskeyRegistrationRequest,
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
/// Send a request to assert a passkey to the desktop client.
pub fn prepare_passkey_assertion(
&self,
request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
/// Send a request to assert a passkey, without prompting the user, to the desktop client.
pub fn prepare_passkey_assertion_without_user_interface(
&self,
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
/// Return the status this client's connection to the desktop client.
pub fn get_connection_status(&self) -> ConnectionStatus {
let is_connected = self
.connection_status
.load(std::sync::atomic::Ordering::Relaxed);
if is_connected {
ConnectionStatus::Connected
} else {
ConnectionStatus::Disconnected
}
}
}
#[cfg(target_os = "macos")]
#[uniffi::export]
pub fn initialize_logging() {
INIT.call_once(|| {
let filter = EnvFilter::builder()
// Everything logs at `INFO`
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::registry()
.with(filter)
.with(tracing_oslog::OsLogger::new(
"com.bitwarden.desktop.autofill-extension",
"default",
))
.init();
});
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "camelCase")]
enum CommandMessage {
Connected,
Disconnected,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged, rename_all = "camelCase")]
enum SerializedMessage {
Command(CommandMessage),
Message {
sequence_number: u32,
value: Result<serde_json::Value, BitwardenError>,
},
}
impl AutofillProviderClient {
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
let sequence_number = self
.response_callbacks_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.insert(sequence_number, (callback, Instant::now()));
sequence_number
}
fn send_message(
&self,
message: impl Serialize + DeserializeOwned,
callback: Option<Box<dyn Callback>>,
) {
if let ConnectionStatus::Disconnected = self.get_connection_status() {
if let Some(callback) = callback {
callback.error(BitwardenError::Disconnected);
}
return;
}
let sequence_number = if let Some(callback) = callback {
self.add_callback(callback)
} else {
NO_CALLBACK_INDICATOR
};
if let Err(e) = send_message_helper(sequence_number, message, &self.to_server_send) {
// Make sure we remove the callback from the queue if we can't send the message
if sequence_number != NO_CALLBACK_INDICATOR {
if let Some((callback, _)) = self
.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.remove(&sequence_number)
{
callback.error(BitwardenError::Internal(format!(
"Error sending message: {e}"
)));
}
}
}
}
}
// Wrapped in Result<> to allow using ? for clarity.
fn send_message_helper(
sequence_number: u32,
message: impl Serialize + DeserializeOwned,
tx: &tokio::sync::mpsc::Sender<String>,
) -> Result<(), BitwardenError> {
let value = serde_json::to_value(message).map_err(|err| {
BitwardenError::Internal(format!("Could not represent message as JSON: {err}"))
})?;
let message = SerializedMessage::Message {
sequence_number,
value: Ok(value),
};
let json = serde_json::to_string(&message).map_err(|err| {
BitwardenError::Internal(format!("Could not serialize message as JSON: {err}"))
})?;
// The OS calls us serially, and we only need 1-3 concurrent requests
// (passkey request, cancellation, maybe user verification).
// So it's safe to send on this thread since there should always be enough
// room in the receiver buffer to send.
tx.blocking_send(json)
.map_err(|_| BitwardenError::Disconnected)?;
Ok(())
}
/// Types of errors for callbacks.
#[derive(Debug)]
pub enum CallbackError {
Timeout,
Cancelled,
}
impl Display for CallbackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Timeout => f.write_str("Callback timed out"),
Self::Cancelled => f.write_str("Callback cancelled"),
}
}
}
impl std::error::Error for CallbackError {}
type CallbackResponse<T> = Result<T, BitwardenError>;
/// An implementation of a callback handler that can take a deadline.
pub struct TimedCallback<T> {
tx: Arc<Mutex<Option<Sender<CallbackResponse<T>>>>>,
rx: Arc<Mutex<Receiver<CallbackResponse<T>>>>,
}
impl<T: Send + 'static> Default for TimedCallback<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Send + 'static> TimedCallback<T> {
/// Instantiates a new callback handler.
pub fn new() -> Self {
let (tx, rx) = mpsc::channel();
Self {
tx: Arc::new(Mutex::new(Some(tx))),
rx: Arc::new(Mutex::new(rx)),
}
}
/// Block the current thread until either a response is received, or the
/// specified timeout has passed.
///
/// # Examples
///
/// ```no_run
/// use std::{sync::Arc, time::Duration};
///
/// use autofill_provider::{AutofillProviderClient, TimedCallback};
///
/// let client = AutofillProviderClient::connect();
/// let callback = Arc::new(TimedCallback::new());
/// client.get_lock_status(callback.clone());
/// match callback.wait_for_response(Duration::from_secs(3), None) {
/// Ok(Ok(response)) => Ok(response),
/// Ok(Err(err)) => Err(format!("GetLockStatus() call failed: {err}")),
/// Err(_) => Err(format!("GetLockStatus() call timed out")),
/// }.unwrap();
/// ```
pub fn wait_for_response(
&self,
timeout: Duration,
cancellation_token: Option<Receiver<()>>,
) -> Result<Result<T, BitwardenError>, CallbackError> {
let (tx, rx) = mpsc::channel();
if let Some(cancellation_token) = cancellation_token {
let tx2 = tx.clone();
let cancellation_token = Mutex::new(cancellation_token);
std::thread::spawn(move || {
if let Ok(()) = cancellation_token
.lock()
.expect("not poisoned")
.recv_timeout(timeout)
{
tracing::debug!("Forwarding cancellation");
_ = tx2.send(Err(CallbackError::Cancelled));
}
});
}
let response_rx = self.rx.clone();
std::thread::spawn(move || {
if let Ok(response) = response_rx
.lock()
.expect("not poisoned")
.recv_timeout(timeout)
{
_ = tx.send(Ok(response));
}
});
match rx.recv_timeout(timeout) {
Ok(Ok(response)) => Ok(response),
Ok(err @ Err(CallbackError::Cancelled)) => {
tracing::debug!("Received cancellation, dropping.");
err
}
Ok(err @ Err(CallbackError::Timeout)) => {
tracing::warn!("Request timed out, dropping.");
err
}
Err(RecvTimeoutError::Timeout) => Err(CallbackError::Timeout),
Err(_) => Err(CallbackError::Cancelled),
}
}
fn send(&self, response: Result<T, BitwardenError>) {
match self.tx.lock().expect("not poisoned").take() {
Some(tx) => {
if tx.send(response).is_err() {
tracing::error!("Windows provider channel closed before receiving IPC response from Electron");
}
}
None => {
tracing::error!("Callback channel used before response: multi-threading issue?");
}
}
}
}
impl PreparePasskeyRegistrationCallback for TimedCallback<PasskeyRegistrationResponse> {
fn on_complete(&self, credential: PasskeyRegistrationResponse) {
self.send(Ok(credential));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}
#[cfg(test)]
mod tests {
//! For debugging test failures, it may be useful to enable tracing to see
//! the request flow more easily. You can do that by adding the following
//! line to the beginning of the `#[test]` function you're working on:
//!
//! ```no_run
//! tracing_subscriber::fmt::init();
//! ```
//!
//! After that, you can set `RUST_LOG=debug` and run `cargo test` to see the traces.
use std::{
path::PathBuf,
sync::{atomic::AtomicU32, Arc},
time::Duration,
};
use desktop_core::ipc::server::MessageType;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tracing::Level;
use crate::{
AutofillProviderClient, BitwardenError, ConnectionStatus, LockStatusRequest,
SerializedMessage, TimedCallback, IPC_PATH,
};
/// Generates a path for a server and client to connect with.
///
/// [`AutofillProviderClient`] is currently hardcoded to use sockets from the filesystem.
/// In order for paths not to conflict between tests, we use a counter and add it to the path
/// name.
fn get_server_path() -> PathBuf {
static SERVER_COUNTER: AtomicU32 = AtomicU32::new(0);
let counter = SERVER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let name = format!("{}-{}", IPC_PATH, counter);
desktop_core::ipc::path(&name)
}
/// Sets up an in-memory server based on the passed handler and returns a client to the server.
fn get_client<
F: Fn(Result<Value, BitwardenError>) -> Result<Value, BitwardenError> + Send + 'static,
>(
handler: F,
) -> AutofillProviderClient {
let (signal_tx, signal_rx) = std::sync::mpsc::channel();
let path = get_server_path();
let server_path = path.clone();
// Start server thread
std::thread::spawn(move || {
let _span = tracing::span!(Level::DEBUG, "server").entered();
tracing::info!("Starting server thread");
let (tx, mut rx) = mpsc::channel(8);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.unwrap();
rt.block_on(async move {
tracing::debug!(?server_path, "Starting server");
let server = desktop_core::ipc::server::Server::start(&server_path, tx).unwrap();
// Signal to main thread that the server is ready to process messages.
tracing::debug!("Server started");
signal_tx.send(()).unwrap();
// Handle incoming messages
tracing::debug!("Waiting for messages");
while let Some(data) = rx.recv().await {
tracing::debug!("Received {data:?}");
match data.kind {
MessageType::Connected => {}
MessageType::Disconnected => {}
MessageType::Message => {
// Deserialize and handle messages using the given handler function.
let msg: SerializedMessage =
serde_json::from_str(&data.message.unwrap()).unwrap();
if let SerializedMessage::Message {
sequence_number,
value,
} = msg
{
let response = serde_json::to_string(&SerializedMessage::Message {
sequence_number,
value: handler(value),
})
.unwrap();
server.send(response).unwrap();
}
}
}
}
});
});
// Wait for server to startup and client to connect to server before returning client to
// test method.
let _span = tracing::span!(Level::DEBUG, "client");
tracing::debug!("Waiting for server...");
signal_rx.recv_timeout(Duration::from_millis(1000)).unwrap();
// This starts a background task to connect to the server.
tracing::debug!("Starting client...");
let client = AutofillProviderClient::connect_to_path(path.to_path_buf());
// The client connects to the server asynchronously in a background
// thread, so wait for client to report itself as Connected so that test
// methods don't have to do this everytime.
// Note, this has the potential to be flaky on a very busy server, but that's unavoidable
// with the current API.
tracing::debug!("Client connecting...");
for _ in 0..20 {
if let ConnectionStatus::Connected = client.get_connection_status() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(matches!(
client.get_connection_status(),
ConnectionStatus::Connected
));
client
}
#[test]
fn test_client_throws_error_on_method_call_when_disconnected() {
// There is no server running at this path, so this client should always be disconnected.
let client = AutofillProviderClient::connect_to_path(get_server_path());
// use an arbitrary request to test whether the client is disconnected.
let callback = Arc::new(TimedCallback::new());
client.get_lock_status(callback.clone());
let response = callback
.wait_for_response(Duration::from_millis(10), None)
.unwrap();
assert!(matches!(response, Err(BitwardenError::Disconnected)));
}
#[test]
fn test_client_parses_get_lock_status_response_when_valid_json_is_returned() {
// The server should expect a lock status request and return a valid response.
let handler = |value: Result<Value, BitwardenError>| {
let value = value.unwrap();
if let Ok(LockStatusRequest {}) = serde_json::from_value(value.clone()) {
Ok(json!({"isUnlocked": true}))
} else {
Err(BitwardenError::Internal(format!(
"Expected LockStatusRequest, received: {value:?}"
)))
}
};
// send a lock status request
let client = get_client(handler);
let callback = Arc::new(TimedCallback::new());
client.get_lock_status(callback.clone());
let response = callback
.wait_for_response(Duration::from_millis(3000), None)
.unwrap()
.unwrap();
assert!(response.is_unlocked);
}
}

View File

@@ -0,0 +1,48 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, TimedCallback};
/// Request to retrieve the lock status of the desktop client.
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct LockStatusRequest {}
/// Response for the lock status of the desktop client.
#[derive(Debug, Deserialize)]
pub struct LockStatusResponse {
/// Whether the desktop client is unlocked.
#[serde(rename = "isUnlocked")]
pub is_unlocked: bool,
}
impl Callback for Arc<dyn GetLockStatusCallback> {
fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> {
let response = serde_json::from_value(response)?;
self.as_ref().on_complete(response);
Ok(())
}
fn error(&self, error: BitwardenError) {
self.as_ref().on_error(error);
}
}
/// Callback to process a response to a lock status request.
pub trait GetLockStatusCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, response: LockStatusResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl GetLockStatusCallback for TimedCallback<LockStatusResponse> {
fn on_complete(&self, response: LockStatusResponse) {
self.send(Ok(response));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}

View File

@@ -0,0 +1,107 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, Position, UserVerification};
/// Request to create a credential.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationRequest {
/// Relying Party ID for the request.
pub rp_id: String,
/// The user name for the credential that was previously given to the OS.
pub user_name: String,
/// The user ID for the credential that was previously given to the OS.
pub user_handle: Vec<u8>,
/// SHA-256 hash of the `clientDataJSON` for the registration request.
pub client_data_hash: Vec<u8>,
/// User verification preference.
pub user_verification: UserVerification,
/// Supported key algorithms in COSE format.
pub supported_algorithms: Vec<i32>,
/// Coordinates of the center of the WebAuthn client's window, relative to
/// the top-left point on the screen.
/// # Operating System Differences
///
/// ## macOS
/// Note that macOS APIs gives points relative to the bottom-left point on the
/// screen by default, so the y-coordinate will be flipped.
///
/// ## Windows
/// On Windows, this must be logical pixels, not physical pixels.
pub window_xy: Position,
/// List of excluded credential IDs.
pub excluded_credentials: Vec<Vec<u8>>,
/// Byte string representing the native OS window handle for the WebAuthn client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[cfg(not(target_os = "macos"))]
pub client_window_handle: Vec<u8>,
/// Native context required for callbacks to the OS. Format differs by OS.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a base64-string representing the following data:
/// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)`
#[cfg(not(target_os = "macos"))]
pub context: String,
}
/// Response for a passkey registration request.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
/// Relying Party ID.
pub rp_id: String,
/// SHA-256 hash of the `clientDataJSON` used in the registration.
pub client_data_hash: Vec<u8>,
/// The ID for the created credential.
pub credential_id: Vec<u8>,
/// WebAuthn attestation object.
pub attestation_object: Vec<u8>,
}
/// Callback to process a response to passkey registration request.
#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))]
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, credential: PasskeyRegistrationResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
}
}

View File

@@ -0,0 +1,75 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_with::{
base64::{Base64, Standard},
formats::Padded,
serde_as,
};
use crate::{BitwardenError, Callback, TimedCallback};
/// Request to get the window handle of the desktop client.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct WindowHandleQueryRequest {
/// Marker field for parsing; data is never read.
///
/// TODO: this is used to disambiguate parsing the type in desktop_napi.
/// This will be cleaned up in PM-23485.
window_handle: String,
}
/// Response to window handle request.
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowHandleQueryResponse {
/// Whether the desktop client is currently visible.
pub is_visible: bool,
/// Whether the desktop client is currently focused.
pub is_focused: bool,
/// Byte string representing the native OS window handle for the desktop client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[serde_as(as = "Base64<Standard, Padded>")]
pub handle: Vec<u8>,
}
impl Callback for Arc<dyn GetWindowHandleQueryCallback> {
fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> {
let response = serde_json::from_value(response)?;
self.as_ref().on_complete(response);
Ok(())
}
fn error(&self, error: BitwardenError) {
self.as_ref().on_error(error);
}
}
/// Callback to process a response to a window handle query request.
pub trait GetWindowHandleQueryCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, response: WindowHandleQueryResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl GetWindowHandleQueryCallback for TimedCallback<WindowHandleQueryResponse> {
fn on_complete(&self, response: WindowHandleQueryResponse) {
self.send(Ok(response));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}

View File

@@ -0,0 +1,9 @@
#[cfg(target_os = "macos")]
fn main() {
uniffi::uniffi_bindgen_main()
}
#[cfg(not(target_os = "macos"))]
fn main() {
unimplemented!("uniffi-bindgen is not enabled on this target.");
}

View File

@@ -1,58 +0,0 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
rp_id: String,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
allowed_credentials: Vec<Vec<u8>>,
window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
rp_id: String,
credential_id: Vec<u8>,
user_name: String,
user_handle: Vec<u8>,
record_identifier: Option<String>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
window_xy: Position,
}
#[derive(uniffi::Record, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
rp_id: String,
user_handle: Vec<u8>,
signature: Vec<u8>,
client_data_hash: Vec<u8>,
authenticator_data: Vec<u8>,
credential_id: Vec<u8>,
}
#[uniffi::export(with_foreign)]
pub trait PreparePasskeyAssertionCallback: Send + Sync {
fn on_complete(&self, credential: PasskeyAssertionResponse);
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
}
}

View File

@@ -1,296 +0,0 @@
#![cfg(target_os = "macos")]
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
use std::{
collections::HashMap,
sync::{atomic::AtomicU32, Arc, Mutex, Once},
time::Instant,
};
use futures::FutureExt;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{error, info};
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
layer::SubscriberExt,
util::SubscriberInitExt,
};
uniffi::setup_scaffolding!();
mod assertion;
mod registration;
use assertion::{
PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
};
use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback};
static INIT: Once = Once::new();
#[derive(uniffi::Enum, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {
Preferred,
Required,
Discouraged,
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[derive(Debug, uniffi::Error, Serialize, Deserialize)]
pub enum BitwardenError {
Internal(String),
}
// TODO: These have to be named differently than the actual Uniffi traits otherwise
// the generated code will lead to ambiguous trait implementations
// These are only used internally, so it doesn't matter that much
trait Callback: Send + Sync {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
fn error(&self, error: BitwardenError);
}
#[derive(uniffi::Enum, Debug)]
/// Store the connection status between the macOS credential provider extension
/// and the desktop application's IPC server.
pub enum ConnectionStatus {
Connected,
Disconnected,
}
#[derive(uniffi::Object)]
pub struct MacOSProviderClient {
to_server_send: tokio::sync::mpsc::Sender<String>,
// We need to keep track of the callbacks so we can call them when we receive a response
response_callbacks_counter: AtomicU32,
#[allow(clippy::type_complexity)]
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
// Flag to track connection status - atomic for thread safety without locks
connection_status: Arc<std::sync::atomic::AtomicBool>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Store native desktop status information to use for IPC communication
/// between the application and the macOS credential provider.
pub struct NativeStatus {
key: String,
value: String,
}
// In our callback management, 0 is a reserved sequence number indicating that a message does not
// have a callback.
const NO_CALLBACK_INDICATOR: u32 = 0;
#[uniffi::export]
impl MacOSProviderClient {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
#[uniffi::constructor]
pub fn connect() -> Self {
INIT.call_once(|| {
let filter = EnvFilter::builder()
// Everything logs at `INFO`
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::registry()
.with(filter)
.with(tracing_oslog::OsLogger::new(
"com.bitwarden.desktop.autofill-extension",
"default",
))
.init();
});
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32);
let client = MacOSProviderClient {
to_server_send,
response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
* "no callback" scenarios */
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let path = desktop_core::ipc::path("af");
let queue = client.response_callbacks_queue.clone();
let connection_status = client.connection_status.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Can't create runtime");
rt.spawn(
desktop_core::ipc::client::connect(path, from_server_send, to_server_recv)
.map(|r| r.map_err(|e| e.to_string())),
);
rt.block_on(async move {
while let Some(message) = from_server_recv.recv().await {
match serde_json::from_str::<SerializedMessage>(&message) {
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
info!("Connected to server");
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
info!("Disconnected from server");
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Message {
sequence_number,
value,
}) => match queue.lock().unwrap().remove(&sequence_number) {
Some((cb, request_start_time)) => {
info!(
"Time to process request: {:?}",
request_start_time.elapsed()
);
match value {
Ok(value) => {
if let Err(e) = cb.complete(value) {
error!(error = %e, "Error deserializing message");
}
}
Err(e) => {
error!(error = ?e, "Error processing message");
cb.error(e)
}
}
}
None => {
error!(sequence_number, "No callback found for sequence number")
}
},
Err(e) => {
error!(error = %e, "Error deserializing message");
}
};
}
});
});
client
}
pub fn send_native_status(&self, key: String, value: String) {
let status = NativeStatus { key, value };
self.send_message(status, None);
}
pub fn prepare_passkey_registration(
&self,
request: PasskeyRegistrationRequest,
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
pub fn prepare_passkey_assertion(
&self,
request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
pub fn prepare_passkey_assertion_without_user_interface(
&self,
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
pub fn get_connection_status(&self) -> ConnectionStatus {
let is_connected = self
.connection_status
.load(std::sync::atomic::Ordering::Relaxed);
if is_connected {
ConnectionStatus::Connected
} else {
ConnectionStatus::Disconnected
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "camelCase")]
enum CommandMessage {
Connected,
Disconnected,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged, rename_all = "camelCase")]
enum SerializedMessage {
Command(CommandMessage),
Message {
sequence_number: u32,
value: Result<serde_json::Value, BitwardenError>,
},
}
impl MacOSProviderClient {
#[allow(clippy::unwrap_used)]
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
let sequence_number = self
.response_callbacks_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.insert(sequence_number, (callback, Instant::now()));
sequence_number
}
#[allow(clippy::unwrap_used)]
fn send_message(
&self,
message: impl Serialize + DeserializeOwned,
callback: Option<Box<dyn Callback>>,
) {
let sequence_number = if let Some(callback) = callback {
self.add_callback(callback)
} else {
NO_CALLBACK_INDICATOR
};
let message = serde_json::to_string(&SerializedMessage::Message {
sequence_number,
value: Ok(serde_json::to_value(message).unwrap()),
})
.expect("Can't serialize message");
if let Err(e) = self.to_server_send.blocking_send(message) {
// Make sure we remove the callback from the queue if we can't send the message
if sequence_number != NO_CALLBACK_INDICATOR {
if let Some((callback, _)) = self
.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.remove(&sequence_number)
{
callback.error(BitwardenError::Internal(format!(
"Error sending message: {e}"
)));
}
}
}
}
}

View File

@@ -1,45 +0,0 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationRequest {
rp_id: String,
user_name: String,
user_handle: Vec<u8>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
supported_algorithms: Vec<i32>,
window_xy: Position,
excluded_credentials: Vec<Vec<u8>>,
}
#[derive(uniffi::Record, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
rp_id: String,
client_data_hash: Vec<u8>,
credential_id: Vec<u8>,
attestation_object: Vec<u8>,
}
#[uniffi::export(with_foreign)]
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
fn on_complete(&self, credential: PasskeyRegistrationResponse);
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
}
}

View File

@@ -1,3 +0,0 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

View File

@@ -15,7 +15,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
@IBOutlet weak var logoImageView: NSImageView!
// The IPC client to communicate with the Bitwarden desktop app
private var client: MacOsProviderClient?
private var client: AutofillProviderClient?
// Timer for checking connection status
private var connectionMonitorTimer: Timer?
@@ -25,11 +25,12 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
// This is so that we can check if the app is running, and launch it, without blocking the main thread
// Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0.
// We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc.
private func getClient() async -> MacOsProviderClient {
private func getClient() async -> AutofillProviderClient {
if let client = self.client {
return client
}
initializeLogging()
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
// Check if the Electron app is running
@@ -61,13 +62,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
// Retry connecting to the Bitwarden IPC with an increasing delay
let maxRetries = 20
let delayMs = 500
var newClient: MacOsProviderClient?
var newClient: AutofillProviderClient?
for attempt in 1...maxRetries {
logger.log("[autofill-extension] Connection attempt \(attempt)")
// Create a new client instance for each retry
newClient = MacOsProviderClient.connect()
newClient = AutofillProviderClient.connect()
try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds
let connectionStatus = newClient!.getConnectionStatus()
@@ -129,7 +130,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
// If we just disconnected, try to cancel the request
if currentStatus == .disconnected {
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected"))
self.extensionContext.cancelRequest(withError: BitwardenError.Disconnected)
}
}
}

View File

@@ -17,7 +17,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/autofill_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; };
968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = "<group>"; };
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = "<group>"; };

View File

@@ -19,7 +19,7 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.19.3",
"@types/node": "22.19.7",
"typescript": "5.4.2"
}
},
@@ -117,10 +117,11 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -379,6 +380,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -24,7 +24,7 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.19.3",
"@types/node": "22.19.7",
"typescript": "5.4.2"
},
"_moduleAliases": {

View File

@@ -18,16 +18,16 @@
"scripts": {
"postinstall": "electron-rebuild",
"start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build",
"build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform",
"build-native-macos": "cd desktop_native && ./autofill_provider/build.sh && node build.js cross-platform",
"build-native": "cd desktop_native && node build.js",
"build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
"build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"",
"build:preload": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload",
"build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload",
"build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload --watch",
"build:macos-extension:mac": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mac",
"build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas",
"build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
"build:macos-extension:mac": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mac",
"build:macos-extension:mas": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas",
"build:macos-extension:masdev": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
"build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main",
"build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main",
"build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch",

View File

@@ -514,7 +514,7 @@ export class vNextMembersComponent {
if (result.error != null) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t(result.error),
message: result.error,
});
this.logService.error(result.error);
return;

View File

@@ -63,7 +63,7 @@
</bit-select>
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'access' | i18n }}">
<bit-tab [label]="accessTabLabel">
<div class="tw-mb-3">
<ng-container *ngIf="dialogReadonly">
<span>{{ "readOnlyCollectionAccess" | i18n }}</span>

View File

@@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
return this.params.readonly === true;
}
protected get accessTabLabel(): string {
return this.dialogReadonly
? this.i18nService.t("viewAccess")
: this.i18nService.t("editAccess");
}
protected async cancel() {
this.close(CollectionDialogAction.Canceled);
}

View File

@@ -57,12 +57,8 @@
<ng-container *ngIf="subscription">
<ng-container *ngIf="enableDiscountDisplay$ | async as enableDiscount; else noDiscount">
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
{{
(sub.subscription.periodEndDate | date: "MMM d, y") +
", " +
(discountedSubscriptionAmount | currency: "$")
}}
<span [attr.aria-label]="'nextChargeDate' | i18n">
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
</span>
<billing-discount-badge
[discount]="getDiscount(sub?.customerDiscount)"
@@ -71,12 +67,8 @@
</ng-container>
<ng-template #noDiscount>
<div class="tw-flex tw-items-center tw-gap-2 tw-flex-wrap tw-justify-end">
<span [attr.aria-label]="'nextChargeDateAndAmount' | i18n">
{{
(sub.subscription.periodEndDate | date: "MMM d, y") +
", " +
(subscriptionAmount | currency: "$")
}}
<span [attr.aria-label]="'nextChargeDate' | i18n">
{{ sub.subscription.periodEndDate | date: "MMM d, y" }}
</span>
</div>
</ng-template>

View File

@@ -722,6 +722,8 @@ export class EventService {
return ["bwi-browser", this.i18nService.t("webVault") + " - Edge"];
case DeviceType.IEBrowser:
return ["bwi-browser", this.i18nService.t("webVault") + " - IE"];
case DeviceType.DuckDuckGoBrowser:
return ["bwi-browser", this.i18nService.t("webVault") + " - DuckDuckGo"];
case DeviceType.Server:
return ["bwi-user-monitor", this.i18nService.t("server")];
case DeviceType.WindowsCLI:

View File

@@ -3281,6 +3281,9 @@
"nextChargeHeader": {
"message": "Next Charge"
},
"nextChargeDate": {
"message": "Next charge date"
},
"plan": {
"message": "Plan"
},
@@ -6928,8 +6931,8 @@
"activateAutofill": {
"message": "Activate auto-fill"
},
"activateAutofillPolicyDesc": {
"message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members."
"activateAutofillPolicyDescription": {
"message": "Activate the autofill on page load setting on the browser extension for all existing and new members."
},
"experimentalFeature": {
"message": "Compromised or untrusted websites can exploit auto-fill on page load."
@@ -11366,6 +11369,18 @@
"automaticDomainClaimProcess": {
"message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain cant be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed."
},
"automaticDomainClaimProcess1": {
"message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days."
},
"automaticDomainClaimProcess2": {
"message": "Once claimed, existing members with claimed domains will be emailed about the "
},
"accountOwnershipChange": {
"message": "account ownership change"
},
"automaticDomainClaimProcessEnd": {
"message": "."
},
"domainNotClaimed": {
"message": "$DOMAIN$ not claimed. Check your DNS records.",
"placeholders": {
@@ -11378,8 +11393,8 @@
"domainStatusClaimed": {
"message": "Claimed"
},
"domainStatusUnderVerification": {
"message": "Under verification"
"domainStatusPending": {
"message": "Pending"
},
"claimedDomainsDescription": {
"message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts."

View File

@@ -10,22 +10,34 @@
{{ "claimDomain" | i18n }}
</span>
<span *ngIf="data.orgDomain" class="tw-text-xs tw-text-muted">
{{ data.orgDomain.domainName }}
</span>
<span *ngIf="data?.orgDomain && !data.orgDomain?.verifiedDate" bitBadge variant="warning">
{{ "domainStatusUnderVerification" | i18n }}
{{ "domainStatusPending" | i18n }}
</span>
<span *ngIf="data?.orgDomain && data?.orgDomain?.verifiedDate" bitBadge variant="success">
{{ "domainStatusClaimed" | i18n }}
</span>
</span>
<div bitDialogContent>
<div *ngIf="data?.orgDomain && !data?.orgDomain?.verifiedDate" class="tw-space-y-2 tw-pb-4">
<p bitTypography="body1">{{ "automaticDomainClaimProcess1" | i18n }}</p>
<p bitTypography="body1">
{{ "automaticDomainClaimProcess2" | i18n }}
<a
bitLink
target="_blank"
rel="noreferrer"
href="https://bitwarden.com/help/claimed-accounts/"
class="tw-inline-flex tw-items-center tw-gap-1"
>
{{ "accountOwnershipChange" | i18n }}
<i class="bwi bwi-external-link tw-text-xs" aria-hidden="true"></i>
</a>
{{ "automaticDomainClaimProcessEnd" | i18n }}
</p>
</div>
<bit-form-field>
<bit-label>{{ "domainName" | i18n }}</bit-label>
<input bitInput appAutofocus formControlName="domainName" [showErrorsWhenDisabled]="true" />
<bit-hint>{{ "claimDomainNameInputHint" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field *ngIf="data?.orgDomain">
@@ -40,14 +52,6 @@
(click)="copyDnsTxt()"
></button>
</bit-form-field>
<bit-callout
*ngIf="data?.orgDomain && !data?.orgDomain?.verifiedDate"
type="info"
title="{{ 'automaticClaimedDomains' | i18n | uppercase }}"
>
{{ "automaticDomainClaimProcess" | i18n }}
</bit-callout>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">

View File

@@ -54,7 +54,7 @@
</td>
<td bitCell>
<span *ngIf="!orgDomain?.verifiedDate" bitBadge variant="warning">{{
"domainStatusUnderVerification" | i18n
"domainStatusPending" | i18n
}}</span>
<span *ngIf="orgDomain?.verifiedDate" bitBadge variant="success">{{
"domainStatusClaimed" | i18n

View File

@@ -12,7 +12,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class ActivateAutofillPolicy extends BasePolicyEditDefinition {
name = "activateAutofill";
description = "activateAutofillPolicyDesc";
description = "activateAutofillPolicyDescription";
type = PolicyType.ActivateAutofill;
component = ActivateAutofillPolicyComponent;

View File

@@ -94,7 +94,12 @@
[bitAction]="loadMoreEvents"
*ngIf="continuationToken"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<i
*ngIf="loading"
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span>{{ "loadMore" | i18n }}</span>
</button>
</ng-container>

View File

@@ -18,6 +18,7 @@ export enum FeatureFlag {
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
SafariAccountSwitching = "pm-5594-safari-account-switching",
/* Autofill */
MacOsNativeCredentialSync = "macos-native-credential-sync",
@@ -134,6 +135,7 @@ export const DefaultFeatureFlagValue = {
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
[FeatureFlag.SafariAccountSwitching]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,

View File

@@ -16,11 +16,6 @@ export abstract class VaultSettingsService {
* An observable monitoring the state of the show identities on the current tab.
*/
abstract showIdentitiesCurrentTab$: Observable<boolean>;
/**
* An observable monitoring the state of the click items on the Vault view
* for Autofill suggestions.
*/
abstract clickItemsToAutofillVaultView$: Observable<boolean>;
/**
* Saves the enable passkeys setting to disk.
@@ -37,10 +32,4 @@ export abstract class VaultSettingsService {
* @param value The new value for the show identities on tab page setting.
*/
abstract setShowIdentitiesCurrentTab(value: boolean): Promise<void>;
/**
* 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<void>;
}

View File

@@ -25,12 +25,3 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition<boolean>(
clearOn: [], // do not clear user settings
},
);
export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition<boolean>(
VAULT_SETTINGS_DISK,
"clickItemsToAutofillOnVaultView",
{
deserializer: (obj) => obj,
clearOn: [], // do not clear user settings
},
);

View File

@@ -1,4 +1,4 @@
import { Observable, combineLatest, map, shareReplay } from "rxjs";
import { Observable, combineLatest, map } from "rxjs";
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
@@ -7,7 +7,6 @@ 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";
@@ -49,17 +48,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
readonly showIdentitiesCurrentTab$: Observable<boolean> =
this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true));
private clickItemsToAutofillVaultViewState: ActiveUserState<boolean> =
this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW);
/**
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
*/
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
this.clickItemsToAutofillVaultViewState.state$.pipe(
map((x) => x ?? false),
shareReplay({ bufferSize: 1, refCount: false }),
);
constructor(
private stateProvider: StateProvider,
private restrictedItemTypesService: RestrictedItemTypesService,
@@ -79,13 +67,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
await this.showIdentitiesCurrentTabState.update(() => value);
}
/**
* {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView}
*/
async setClickItemsToAutofillVaultView(value: boolean): Promise<void> {
await this.clickItemsToAutofillVaultViewState.update(() => value);
}
/**
* {@link VaultSettingsServiceAbstraction.setEnablePasskeys}
*/

16
package-lock.json generated
View File

@@ -106,10 +106,10 @@
"@types/koa__multer": "2.0.7",
"@types/koa__router": "12.0.4",
"@types/koa-bodyparser": "4.3.7",
"@types/koa-json": "2.0.23",
"@types/koa-json": "2.0.24",
"@types/lowdb": "1.0.15",
"@types/lunr": "2.3.7",
"@types/node": "22.19.3",
"@types/node": "22.19.7",
"@types/node-fetch": "2.6.13",
"@types/node-forge": "1.3.14",
"@types/papaparse": "5.5.0",
@@ -15768,9 +15768,9 @@
}
},
"node_modules/@types/koa-json": {
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.23.tgz",
"integrity": "sha512-LJKLFouztosawgU5xrtanK4neLCQKXl+vuVN96YMeVdKTYObLq2Qybggm9V426Jwam8Gi/zOrPw1g+QH0VaEHw==",
"version": "2.0.24",
"resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.24.tgz",
"integrity": "sha512-FF+nQil6YO8vXMuLnOgGHYspSZVVpi+W79m9/s7LBSOQhlX7QY02X3Evk/g1GgWNLbO674AQaziX6OCCKzQ6Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15833,9 +15833,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"

View File

@@ -73,10 +73,10 @@
"@types/koa__multer": "2.0.7",
"@types/koa__router": "12.0.4",
"@types/koa-bodyparser": "4.3.7",
"@types/koa-json": "2.0.23",
"@types/koa-json": "2.0.24",
"@types/lowdb": "1.0.15",
"@types/lunr": "2.3.7",
"@types/node": "22.19.3",
"@types/node": "22.19.7",
"@types/node-fetch": "2.6.13",
"@types/node-forge": "1.3.14",
"@types/papaparse": "5.5.0",