1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 02:03:39 +00:00

Merge branch 'main' into neuronull/reapply-debug-builds-debug-log-level

This commit is contained in:
neuronull
2025-12-10 08:21:46 -07:00
249 changed files with 10627 additions and 4645 deletions

2
.github/CODEOWNERS vendored
View File

@@ -8,7 +8,9 @@
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/core/src/secure_memory @bitwarden/team-key-management-dev
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml

View File

@@ -209,7 +209,7 @@ jobs:
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder
sudo apt-get -y install pkg-config libxss-dev rpm flatpak flatpak-builder
- name: Set up Snap
run: sudo snap install snapcraft --classic
@@ -262,12 +262,10 @@ jobs:
env:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
# Note: It is important that we use the release build because some compute heavy
# operations such as key derivation for oo7 on linux are too slow in debug mode
run: |
rustup target add x86_64-unknown-linux-musl
node build.js --target=x86_64-unknown-linux-musl --release
node build.js --release
- name: Build application
run: npm run dist:lin
@@ -367,7 +365,7 @@ jobs:
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder squashfs-tools ruby ruby-dev rubygems build-essential
sudo apt-get -y install pkg-config libxss-dev rpm flatpak flatpak-builder squashfs-tools ruby ruby-dev rubygems build-essential
sudo gem install --no-document fpm
- name: Set up Snap
@@ -427,12 +425,10 @@ jobs:
env:
PKG_CONFIG_ALLOW_CROSS: true
PKG_CONFIG_ALL_STATIC: true
TARGET: musl
# Note: It is important that we use the release build because some compute heavy
# operations such as key derivation for oo7 on linux are too slow in debug mode
run: |
rustup target add aarch64-unknown-linux-musl
node build.js --target=aarch64-unknown-linux-musl --release
node build.js --release
- name: Check index.d.ts generated
if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true'
@@ -1023,10 +1019,10 @@ jobs:
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.14'
python-version: '3.14.2'
- name: Set up Node-gyp
run: python3 -m pip install setuptools
run: python -m pip install setuptools
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -1042,6 +1038,7 @@ jobs:
rustup show
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
xcodebuild -showsdks
- name: Cache Build
id: build-cache
@@ -1262,10 +1259,10 @@ jobs:
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.14'
python-version: '3.14.2'
- name: Set up Node-gyp
run: python3 -m pip install setuptools
run: python -m pip install setuptools
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -1281,6 +1278,7 @@ jobs:
rustup show
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
xcodebuild -showsdks
- name: Get Build Cache
id: build-cache
@@ -1536,10 +1534,10 @@ jobs:
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.14'
python-version: '3.14.2'
- name: Set up Node-gyp
run: python3 -m pip install setuptools
run: python -m pip install setuptools
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -1555,6 +1553,7 @@ jobs:
rustup show
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
xcodebuild -showsdks
- name: Get Build Cache
id: build-cache

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- name: 'Run stale action'
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
stale-issue-label: 'needs-reply'
stale-pr-label: 'needs-changes'

View File

@@ -75,7 +75,7 @@ jobs:
- name: Trigger test-all workflow in browser-interactions-testing
if: steps.changed-files.outputs.monitored == 'true'
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ steps.app-token.outputs.token }}
repository: "bitwarden/browser-interactions-testing"

View File

@@ -1457,6 +1457,15 @@
"attachmentSaved": {
"message": "Attachment saved"
},
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"file": {
"message": "File"
},
@@ -1466,6 +1475,9 @@
"selectFile": {
"message": "Select a file"
},
"itemsTransferred": {
"message": "Items transferred"
},
"maxFileSize": {
"message": "Maximum file size is 500 MB."
},
@@ -5848,8 +5860,8 @@
"andMoreFeatures": {
"message": "And more!"
},
"planDescPremium": {
"message": "Complete online security"
"advancedOnlineSecurity": {
"message": "Advanced online security"
},
"upgradeToPremium": {
"message": "Upgrade to Premium"
@@ -5925,5 +5937,56 @@
},
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
},
"upgrade": {
"message": "Upgrade"
},
"leaveConfirmationDialogTitle": {
"message": "Are you sure you want to leave?"
},
"leaveConfirmationDialogContentOne": {
"message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
},
"leaveConfirmationDialogContentTwo": {
"message": "Contact your admin to regain access."
},
"leaveConfirmationDialogConfirmButton": {
"message": "Leave $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"howToManageMyVault": {
"message": "How do I manage my vault?"
},
"transferItemsToOrganizationTitle": {
"message": "Transfer items to $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"transferItemsToOrganizationContent": {
"message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"acceptTransfer": {
"message": "Accept transfer"
},
"declineAndLeave": {
"message": "Decline and leave"
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
}
}

View File

@@ -2,7 +2,7 @@
<button
*ngIf="currentAccount$ | async as currentAccount; else defaultButton"
type="button"
class="tw-rounded-full hover:tw-outline hover:tw-outline-1 hover:tw-outline-offset-1 hover:tw-outline-primary-600"
class="tw-rounded-full hover:tw-outline hover:tw-outline-1 hover:tw-outline-primary-600"
(click)="currentAccountClicked()"
>
<span class="tw-sr-only"> {{ "bitwardenAccount" | i18n }} {{ currentAccount.email }}</span>

View File

@@ -129,7 +129,12 @@ export class AutofillInlineMenuContainer {
}
try {
const urlObj = new URL(url);
const isExtensionProtocol = /^[a-z]+(-[a-z]+)?-extension:$/i.test(urlObj.protocol);
const extensionProtocols = new Set([
"chrome-extension:",
"moz-extension:",
"safari-web-extension:",
]);
const isExtensionProtocol = extensionProtocols.has(urlObj.protocol);
if (!isExtensionProtocol) {
return false;

View File

@@ -24,6 +24,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
@@ -198,7 +199,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.displayedCiphers = this.ciphers.filter(
(cipher) =>
cipher.login.matchesUri(this.url, equivalentDomains) &&
this.cipherHasNoOtherPasskeys(cipher, message.userHandle),
Fido2Utils.cipherHasNoOtherPasskeys(cipher, message.userHandle),
);
this.passkeyAction = PasskeyActions.Register;
@@ -472,16 +473,4 @@ export class Fido2Component implements OnInit, OnDestroy {
...msg,
});
}
/**
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
private cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
}

View File

@@ -294,19 +294,11 @@ export default class RuntimeBackground {
await this.openPopup();
break;
case VaultMessages.OpenAtRiskPasswords: {
if (await this.shouldRejectManyOriginMessage(msg)) {
return;
}
await this.main.openAtRisksPasswordsPage();
this.announcePopupOpen();
break;
}
case VaultMessages.OpenBrowserExtensionToUrl: {
if (await this.shouldRejectManyOriginMessage(msg)) {
return;
}
await this.main.openTheExtensionToPage(msg.url);
this.announcePopupOpen();
break;

View File

@@ -22,7 +22,7 @@ export type NavButton = {
templateUrl: "popup-tab-navigation.component.html",
imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule],
host: {
class: "tw-block tw-h-full tw-w-full tw-flex tw-flex-col",
class: "tw-block tw-size-full tw-flex tw-flex-col",
},
})
export class PopupTabNavigationComponent {

View File

@@ -13,8 +13,11 @@
</bit-callout>
</div>
} @else {
<div [@routerTransition]="getRouteElevation(outlet)">
<!-- eslint-disable-next-line -->
<div class="tw-h-screen tw-w-screen">
<div [@routerTransition]="getRouteElevation(outlet)" class="tw-size-full">
<router-outlet #outlet="outlet"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
</div>
}

View File

@@ -1,453 +0,0 @@
@import "variables.scss";
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
overflow: hidden;
min-height: 600px;
height: 100%;
&.body-sm {
min-height: 500px;
}
&.body-xs {
min-height: 400px;
}
&.body-xxs {
min-height: 300px;
}
&.body-3xs {
min-height: 240px;
}
&.body-full {
min-height: unset;
width: 100%;
height: 100%;
& body {
width: 100%;
}
}
}
html,
body {
font-family: $font-family-sans-serif;
font-size: $font-size-base;
line-height: $line-height-base;
-webkit-font-smoothing: antialiased;
}
body {
width: 380px;
height: 100%;
position: relative;
min-height: inherit;
overflow: hidden;
color: $text-color;
background-color: $background-color;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $font-family-sans-serif;
font-size: $font-size-base;
font-weight: normal;
}
p {
margin-bottom: 10px;
}
ul,
ol {
margin-bottom: 10px;
}
img {
border: none;
}
a:not(popup-page a, popup-tab-navigation a) {
text-decoration: none;
@include themify($themes) {
color: themed("primaryColor");
}
&:hover,
&:focus {
@include themify($themes) {
color: darken(themed("primaryColor"), 6%);
}
}
}
input:not(bit-form-field input, bit-search input, input[bitcheckbox]),
select:not(bit-form-field select),
textarea:not(bit-form-field textarea) {
@include themify($themes) {
color: themed("textColor");
background-color: themed("inputBackgroundColor");
}
}
input:not(input[bitcheckbox]),
select,
textarea,
button:not(bit-chip-select button) {
font-size: $font-size-base;
font-family: $font-family-sans-serif;
}
input[type*="date"] {
@include themify($themes) {
color-scheme: themed("dateInputColorScheme");
}
}
::-webkit-calendar-picker-indicator {
@include themify($themes) {
filter: themed("webkitCalendarPickerFilter");
}
}
::-webkit-calendar-picker-indicator:hover {
@include themify($themes) {
filter: themed("webkitCalendarPickerHoverFilter");
}
cursor: pointer;
}
select {
width: 100%;
padding: 0.35rem;
}
button {
cursor: pointer;
}
textarea {
resize: vertical;
}
app-root > div {
height: 100%;
width: 100%;
}
main::-webkit-scrollbar,
cdk-virtual-scroll-viewport::-webkit-scrollbar,
.vault-select::-webkit-scrollbar {
width: 10px;
height: 10px;
}
main::-webkit-scrollbar-track,
.vault-select::-webkit-scrollbar-track {
background-color: transparent;
}
cdk-virtual-scroll-viewport::-webkit-scrollbar-track {
@include themify($themes) {
background-color: themed("backgroundColor");
}
}
main::-webkit-scrollbar-thumb,
cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb,
.vault-select::-webkit-scrollbar-thumb {
border-radius: 10px;
margin-right: 1px;
@include themify($themes) {
background-color: themed("scrollbarColor");
}
&:hover {
@include themify($themes) {
background-color: themed("scrollbarHoverColor");
}
}
}
header:not(bit-callout header, bit-dialog header, popup-page header) {
height: 44px;
display: flex;
&:not(.no-theme) {
border-bottom: 1px solid #000000;
@include themify($themes) {
color: themed("headerColor");
background-color: themed("headerBackgroundColor");
border-bottom-color: themed("headerBorderColor");
}
}
.header-content {
display: flex;
flex: 1 1 auto;
}
.header-content > .right,
.header-content > .right > .right {
height: 100%;
}
.left,
.right {
flex: 1;
display: flex;
min-width: -webkit-min-content; /* Workaround to Chrome bug */
.header-icon {
margin-right: 5px;
}
}
.right {
justify-content: flex-end;
align-items: center;
app-avatar {
max-height: 30px;
margin-right: 5px;
}
}
.center {
display: flex;
align-items: center;
text-align: center;
min-width: 0;
}
.login-center {
margin: auto;
}
app-pop-out > button,
div > button:not(app-current-account button):not(.home-acc-switcher-btn),
div > a {
border: none;
padding: 0 10px;
text-decoration: none;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100%;
white-space: pre;
&:not(.home-acc-switcher-btn):hover,
&:not(.home-acc-switcher-btn):focus {
@include themify($themes) {
background-color: themed("headerBackgroundHoverColor");
color: themed("headerColor");
}
}
&[disabled] {
opacity: 0.65;
cursor: default !important;
background-color: inherit !important;
}
i + span {
margin-left: 5px;
}
}
app-pop-out {
display: flex;
padding-right: 0.5em;
}
.title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search {
padding: 7px 10px;
width: 100%;
text-align: left;
position: relative;
display: flex;
.bwi {
position: absolute;
top: 15px;
left: 20px;
@include themify($themes) {
color: themed("headerInputPlaceholderColor");
}
}
input:not(bit-form-field input) {
width: 100%;
margin: 0;
border: none;
padding: 5px 10px 5px 30px;
border-radius: $border-radius;
@include themify($themes) {
background-color: themed("headerInputBackgroundColor");
color: themed("headerInputColor");
}
&::selection {
@include themify($themes) {
// explicitly set text selection to invert foreground/background
background-color: themed("headerInputColor");
color: themed("headerInputBackgroundColor");
}
}
&:focus {
border-radius: $border-radius;
outline: none;
@include themify($themes) {
background-color: themed("headerInputBackgroundFocusColor");
}
}
&::-webkit-input-placeholder {
@include themify($themes) {
color: themed("headerInputPlaceholderColor");
}
}
/** make the cancel button visible in both dark/light themes **/
&[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
height: 15px;
width: 15px;
background-repeat: no-repeat;
mask-image: url("../images/close-button-white.svg");
-webkit-mask-image: url("../images/close-button-white.svg");
@include themify($themes) {
background-color: themed("headerInputColor");
}
}
}
}
.left + .search,
.left + .sr-only + .search {
padding-left: 0;
.bwi {
left: 10px;
}
}
.search + .right {
margin-left: -10px;
}
}
.content {
padding: 15px 5px;
}
app-root {
width: 100%;
height: 100vh;
display: flex;
@include themify($themes) {
background-color: themed("backgroundColor");
}
}
main:not(popup-page main):not(auth-anon-layout main) {
position: absolute;
top: 44px;
bottom: 0;
left: 0;
right: 0;
overflow-y: auto;
overflow-x: hidden;
@include themify($themes) {
background-color: themed("backgroundColor");
}
&.no-header {
top: 0;
}
&.flex {
display: flex;
flex-flow: column;
height: calc(100% - 44px);
}
}
.center-content,
.no-items,
.full-loading-spinner {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-direction: column;
flex-grow: 1;
}
.no-items,
.full-loading-spinner {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.no-items-image {
@include themify($themes) {
content: url("../images/search-desktop" + themed("svgSuffix"));
}
}
.bwi {
margin-bottom: 10px;
@include themify($themes) {
color: themed("disabledIconColor");
}
}
}
// cdk-virtual-scroll
.cdk-virtual-scroll-viewport {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.cdk-virtual-scroll-content-wrapper {
width: 100%;
}

View File

@@ -1,620 +0,0 @@
@import "variables.scss";
.box {
position: relative;
width: 100%;
&.first {
margin-top: 0;
}
.box-header {
margin: 0 10px 5px 10px;
text-transform: uppercase;
display: flex;
@include themify($themes) {
color: themed("headingColor");
}
}
.box-content {
@include themify($themes) {
background-color: themed("backgroundColor");
border-color: themed("borderColor");
}
&.box-content-padded {
padding: 10px 15px;
}
&.condensed .box-content-row,
.box-content-row.condensed {
padding-top: 5px;
padding-bottom: 5px;
}
&.no-hover .box-content-row,
.box-content-row.no-hover {
&:hover,
&:focus {
@include themify($themes) {
background-color: themed("boxBackgroundColor") !important;
}
}
}
&.single-line .box-content-row,
.box-content-row.single-line {
padding-top: 10px;
padding-bottom: 10px;
margin: 5px;
}
&.row-top-padding {
padding-top: 10px;
}
}
.box-footer {
margin: 0 5px 5px 5px;
padding: 0 10px 5px 10px;
font-size: $font-size-small;
button.btn {
font-size: $font-size-small;
padding: 0;
}
button.btn.primary {
font-size: $font-size-base;
padding: 7px 15px;
width: 100%;
&:hover {
@include themify($themes) {
border-color: themed("borderHoverColor") !important;
}
}
}
@include themify($themes) {
color: themed("mutedColor");
}
}
&.list {
margin: 10px 0 20px 0;
.box-content {
.virtual-scroll-item {
display: inline-block;
width: 100%;
}
.box-content-row {
text-decoration: none;
border-radius: $border-radius;
// background-color: $background-color;
@include themify($themes) {
color: themed("textColor");
background-color: themed("boxBackgroundColor");
}
&.padded {
padding-top: 10px;
padding-bottom: 10px;
}
&.no-hover {
&:hover {
@include themify($themes) {
background-color: themed("boxBackgroundColor") !important;
}
}
}
&:hover,
&:focus,
&.active {
@include themify($themes) {
background-color: themed("listItemBackgroundHoverColor");
}
}
&:focus {
border-left: 5px solid #000000;
padding-left: 5px;
@include themify($themes) {
border-left-color: themed("mutedColor");
}
}
.action-buttons {
.row-btn {
padding-left: 5px;
padding-right: 5px;
}
}
.text:not(.no-ellipsis),
.detail {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-main {
display: flex;
min-width: 0;
align-items: normal;
.row-main-content {
min-width: 0;
}
}
}
&.single-line {
.box-content-row {
display: flex;
padding-top: 10px;
padding-bottom: 10px;
margin: 5px;
border-radius: $border-radius;
}
}
}
}
}
.box-content-row {
display: block;
padding: 5px 10px;
position: relative;
z-index: 1;
border-radius: $border-radius;
margin: 3px 5px;
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
&:last-child {
&:before {
border: none;
height: 0;
}
}
&.override-last:last-child:before {
border-bottom: 1px solid #000000;
@include themify($themes) {
border-bottom-color: themed("boxBorderColor");
}
}
&.last:last-child:before {
border-bottom: 1px solid #000000;
@include themify($themes) {
border-bottom-color: themed("boxBorderColor");
}
}
&:after {
content: "";
display: table;
clear: both;
}
&:hover,
&:focus,
&.active {
@include themify($themes) {
background-color: themed("boxBackgroundHoverColor");
}
}
&.pre {
white-space: pre;
overflow-x: auto;
}
&.pre-wrap {
white-space: pre-wrap;
overflow-x: auto;
}
.row-label,
label {
font-size: $font-size-small;
display: block;
width: 100%;
margin-bottom: 5px;
@include themify($themes) {
color: themed("mutedColor");
}
.sub-label {
margin-left: 10px;
}
}
.flex-label {
font-size: $font-size-small;
display: flex;
flex-grow: 1;
margin-bottom: 5px;
@include themify($themes) {
color: themed("mutedColor");
}
> a {
flex-grow: 0;
}
}
.text,
.detail {
display: block;
text-align: left;
@include themify($themes) {
color: themed("textColor");
}
}
.detail {
font-size: $font-size-small;
@include themify($themes) {
color: themed("mutedColor");
}
}
.img-right,
.txt-right {
float: right;
margin-left: 10px;
}
.row-main {
flex-grow: 1;
min-width: 0;
}
&.box-content-row-flex,
.box-content-row-flex,
&.box-content-row-checkbox,
&.box-content-row-link,
&.box-content-row-input,
&.box-content-row-slider,
&.box-content-row-multi {
display: flex;
align-items: center;
word-break: break-all;
&.box-content-row-word-break {
word-break: normal;
}
}
&.box-content-row-multi {
input:not([type="checkbox"]) {
width: 100%;
}
input + label.sr-only + select {
margin-top: 5px;
}
> a,
> button {
padding: 8px 8px 8px 4px;
margin: 0;
@include themify($themes) {
color: themed("dangerColor");
}
}
}
&.box-content-row-multi,
&.box-content-row-newmulti {
padding-left: 10px;
}
&.box-content-row-newmulti {
@include themify($themes) {
color: themed("primaryColor");
}
}
&.box-content-row-checkbox,
&.box-content-row-link,
&.box-content-row-input,
&.box-content-row-slider {
padding-top: 10px;
padding-bottom: 10px;
margin: 5px;
label,
.row-label {
font-size: $font-size-base;
display: block;
width: initial;
margin-bottom: 0;
@include themify($themes) {
color: themed("textColor");
}
}
> span {
@include themify($themes) {
color: themed("mutedColor");
}
}
> input {
margin: 0 0 0 auto;
padding: 0;
}
> * {
margin-right: 15px;
&:last-child {
margin-right: 0;
}
}
}
&.box-content-row-checkbox-left {
justify-content: flex-start;
> input {
margin: 0 15px 0 0;
}
}
&.box-content-row-input {
label {
white-space: nowrap;
}
input {
text-align: right;
&[type="number"] {
max-width: 50px;
}
}
}
&.box-content-row-slider {
input[type="range"] {
height: 10px;
}
input[type="number"] {
width: 45px;
}
label {
white-space: nowrap;
}
}
input:not([type="checkbox"]):not([type="radio"]),
textarea {
border: none;
width: 100%;
background-color: transparent !important;
&::-webkit-input-placeholder {
@include themify($themes) {
color: themed("inputPlaceholderColor");
}
}
&:not([type="file"]):focus {
outline: none;
}
}
select {
width: 100%;
border: 1px solid #000000;
border-radius: $border-radius;
padding: 7px 4px;
@include themify($themes) {
border-color: themed("inputBorderColor");
}
}
.action-buttons {
display: flex;
margin-left: 5px;
&.action-buttons-fixed {
align-self: start;
margin-top: 2px;
}
.row-btn {
cursor: pointer;
padding: 10px 8px;
background: none;
border: none;
@include themify($themes) {
color: themed("boxRowButtonColor");
}
&:hover,
&:focus {
@include themify($themes) {
color: themed("boxRowButtonHoverColor");
}
}
&.disabled,
&[disabled] {
@include themify($themes) {
color: themed("disabledIconColor");
opacity: themed("disabledBoxOpacity");
}
&:hover {
@include themify($themes) {
color: themed("disabledIconColor");
opacity: themed("disabledBoxOpacity");
}
}
cursor: default !important;
}
}
&.no-pad .row-btn {
padding-top: 0;
padding-bottom: 0;
}
}
&:not(.box-draggable-row) {
.action-buttons .row-btn:last-child {
margin-right: -3px;
}
}
&.box-draggable-row {
&.box-content-row-checkbox {
input[type="checkbox"] + .drag-handle {
margin-left: 10px;
}
}
}
.drag-handle {
cursor: move;
padding: 10px 2px 10px 8px;
user-select: none;
@include themify($themes) {
color: themed("mutedColor");
}
}
&.cdk-drag-preview {
position: relative;
display: flex;
align-items: center;
opacity: 0.8;
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
}
select.field-type {
margin: 5px 0 0 25px;
width: calc(100% - 25px);
}
.icon {
display: flex;
justify-content: center;
align-items: center;
min-width: 34px;
margin-left: -5px;
@include themify($themes) {
color: themed("mutedColor");
}
&.icon-small {
min-width: 25px;
}
img {
border-radius: $border-radius;
max-height: 20px;
max-width: 20px;
}
}
.progress {
display: flex;
height: 5px;
overflow: hidden;
margin: 5px -15px -10px;
.progress-bar {
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
background-color: $brand-primary;
}
}
.radio-group {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 5px;
input {
flex-grow: 0;
}
label {
margin: 0 0 0 5px;
flex-grow: 1;
font-size: $font-size-base;
display: block;
width: 100%;
@include themify($themes) {
color: themed("textColor");
}
}
&.align-start {
align-items: start;
margin-top: 10px;
label {
margin-top: -4px;
}
}
}
}
.truncate {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
form {
.box {
.box-content {
.box-content-row {
&.no-hover {
&:hover {
@include themify($themes) {
background-color: themed("transparentColor") !important;
}
}
}
}
}
}
}

View File

@@ -1,118 +0,0 @@
@import "variables.scss";
.btn {
border-radius: $border-radius;
padding: 7px 15px;
border: 1px solid #000000;
font-size: $font-size-base;
text-align: center;
cursor: pointer;
@include themify($themes) {
background-color: themed("buttonBackgroundColor");
border-color: themed("buttonBorderColor");
color: themed("buttonColor");
}
&.primary {
@include themify($themes) {
color: themed("buttonPrimaryColor");
}
}
&.danger {
@include themify($themes) {
color: themed("buttonDangerColor");
}
}
&.callout-half {
font-weight: bold;
max-width: 50%;
}
&:hover:not([disabled]) {
cursor: pointer;
@include themify($themes) {
background-color: darken(themed("buttonBackgroundColor"), 1.5%);
border-color: darken(themed("buttonBorderColor"), 17%);
color: darken(themed("buttonColor"), 10%);
}
&.primary {
@include themify($themes) {
color: darken(themed("buttonPrimaryColor"), 6%);
}
}
&.danger {
@include themify($themes) {
color: darken(themed("buttonDangerColor"), 6%);
}
}
}
&:focus:not([disabled]) {
cursor: pointer;
outline: 0;
@include themify($themes) {
background-color: darken(themed("buttonBackgroundColor"), 6%);
border-color: darken(themed("buttonBorderColor"), 25%);
}
}
&[disabled] {
opacity: 0.65;
cursor: default !important;
}
&.block {
display: block;
width: calc(100% - 10px);
margin: 0 auto;
}
&.link,
&.neutral {
border: none !important;
background: none !important;
&:focus {
text-decoration: underline;
}
}
}
.action-buttons {
.btn {
&:focus {
outline: auto;
}
}
}
button.box-content-row {
display: block;
width: calc(100% - 10px);
text-align: left;
border-color: none;
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
}
button {
border: none;
background: transparent;
color: inherit;
}
.login-buttons {
.btn.block {
width: 100%;
margin-bottom: 10px;
}
}

View File

@@ -1,43 +0,0 @@
@import "variables.scss";
html.browser_safari {
&.safari_height_fix {
body {
height: 360px !important;
&.body-xs {
height: 300px !important;
}
&.body-full {
height: 100% !important;
}
}
}
header {
.search .bwi {
left: 20px;
}
.left + .search .bwi {
left: 10px;
}
}
.content {
&.login-page {
padding-top: 100px;
}
}
app-root {
border-width: 1px;
border-style: solid;
border-color: #000000;
}
&.theme_light app-root {
border-color: #777777;
}
}

View File

@@ -1,11 +0,0 @@
.row {
display: flex;
margin: 0 -15px;
width: 100%;
}
.col {
flex-basis: 0;
flex-grow: 1;
padding: 0 15px;
}

View File

@@ -1,348 +0,0 @@
@import "variables.scss";
small,
.small {
font-size: $font-size-small;
}
.bg-primary {
@include themify($themes) {
background-color: themed("primaryColor") !important;
}
}
.bg-success {
@include themify($themes) {
background-color: themed("successColor") !important;
}
}
.bg-danger {
@include themify($themes) {
background-color: themed("dangerColor") !important;
}
}
.bg-info {
@include themify($themes) {
background-color: themed("infoColor") !important;
}
}
.bg-warning {
@include themify($themes) {
background-color: themed("warningColor") !important;
}
}
.text-primary {
@include themify($themes) {
color: themed("primaryColor") !important;
}
}
.text-success {
@include themify($themes) {
color: themed("successColor") !important;
}
}
.text-muted {
@include themify($themes) {
color: themed("mutedColor") !important;
}
}
.text-default {
@include themify($themes) {
color: themed("textColor") !important;
}
}
.text-danger {
@include themify($themes) {
color: themed("dangerColor") !important;
}
}
.text-info {
@include themify($themes) {
color: themed("infoColor") !important;
}
}
.text-warning {
@include themify($themes) {
color: themed("warningColor") !important;
}
}
.text-center {
text-align: center;
}
.font-weight-semibold {
font-weight: 600;
}
p.lead {
font-size: $font-size-large;
margin-bottom: 20px;
font-weight: normal;
}
.flex-right {
margin-left: auto;
}
.flex-bottom {
margin-top: auto;
}
.no-margin {
margin: 0 !important;
}
.display-block {
display: block !important;
}
.monospaced {
font-family: $font-family-monospace;
}
.show-whitespace {
white-space: pre-wrap;
}
.img-responsive {
display: block;
max-width: 100%;
height: auto;
}
.img-rounded {
border-radius: $border-radius;
}
.select-index-top {
position: relative;
z-index: 100;
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
}
:not(:focus) > .exists-only-on-parent-focus {
display: none;
}
.password-wrapper {
overflow-wrap: break-word;
white-space: pre-wrap;
min-width: 0;
}
.password-number {
@include themify($themes) {
color: themed("passwordNumberColor");
}
}
.password-special {
@include themify($themes) {
color: themed("passwordSpecialColor");
}
}
.password-character {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 30px;
height: 36px;
font-weight: 600;
&:nth-child(odd) {
@include themify($themes) {
background-color: themed("backgroundColor");
}
}
}
.password-count {
white-space: nowrap;
font-size: 8px;
@include themify($themes) {
color: themed("passwordCountText") !important;
}
}
#duo-frame {
background: url("../images/loading.svg") 0 0 no-repeat;
width: 100%;
height: 470px;
margin-bottom: -10px;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
#web-authn-frame {
width: 100%;
height: 40px;
iframe {
border: none;
height: 100%;
width: 100%;
}
}
body.linux-webauthn {
width: 485px !important;
#web-authn-frame {
iframe {
width: 375px;
margin: 0 55px;
}
}
}
app-root > #loading {
display: flex;
text-align: center;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
color: $text-muted;
@include themify($themes) {
color: themed("mutedColor");
}
}
app-vault-icon,
.app-vault-icon {
display: flex;
}
.logo-image {
margin: 0 auto;
width: 142px;
height: 21px;
background-size: 142px 21px;
background-repeat: no-repeat;
@include themify($themes) {
background-image: url("../images/logo-" + themed("logoSuffix") + "@2x.png");
}
@media (min-width: 219px) {
width: 189px;
height: 28px;
background-size: 189px 28px;
}
@media (min-width: 314px) {
width: 284px;
height: 43px;
background-size: 284px 43px;
}
}
[hidden] {
display: none !important;
}
.draggable {
cursor: move;
}
input[type="password"]::-ms-reveal {
display: none;
}
.flex {
display: flex;
&.flex-grow {
> * {
flex: 1;
}
}
}
// Text selection styles
// Set explicit selection styles (assumes primary accent color has sufficient
// contrast against the background, so its inversion is also still readable)
// and suppress user selection for most elements (to make it more app-like)
:not(bit-form-field input)::selection {
@include themify($themes) {
color: themed("backgroundColor");
background-color: themed("primaryAccentColor");
}
}
h1,
h2,
h3,
label,
a,
button,
p,
img,
.box-header,
.box-footer,
.callout,
.row-label,
.modal-title,
.overlay-container {
user-select: none;
&.user-select {
user-select: auto;
}
}
/* tweak for inconsistent line heights in cipher view */
.box-footer button,
.box-footer a {
line-height: 1;
}
// Workaround for slow performance on external monitors on Chrome + MacOS
// See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64
@keyframes redraw {
0% {
opacity: 0.99;
}
100% {
opacity: 1;
}
}
html.force_redraw {
animation: redraw 1s linear infinite;
}
/* override for vault icon in browser (pre extension refresh) */
app-vault-icon:not(app-vault-list-items-container app-vault-icon) > div {
display: flex;
justify-content: center;
align-items: center;
float: left;
height: 36px;
width: 34px;
margin-left: -5px;
}

View File

@@ -1,144 +0,0 @@
@import "variables.scss";
app-home {
position: fixed;
height: 100%;
width: 100%;
.center-content {
margin-top: -50px;
height: calc(100% + 50px);
}
img {
width: 284px;
margin: 0 auto;
}
p.lead {
margin: 30px 0;
}
.btn + .btn {
margin-top: 10px;
}
button.settings-icon {
position: absolute;
top: 10px;
left: 10px;
@include themify($themes) {
color: themed("mutedColor");
}
&:not(:hover):not(:focus) {
span {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
&:hover,
&:focus {
text-decoration: none;
@include themify($themes) {
color: themed("primaryColor");
}
}
}
}
body.body-sm,
body.body-xs {
app-home {
.center-content {
margin-top: 0;
height: 100%;
}
p.lead {
margin: 15px 0;
}
}
}
body.body-full {
app-home {
.center-content {
margin-top: -80px;
height: calc(100% + 80px);
}
}
}
.createAccountLink {
padding: 30px 10px 0 10px;
}
.remember-email-check {
padding-top: 18px;
padding-left: 10px;
padding-bottom: 18px;
}
.login-buttons > button {
margin: 15px 0 15px 0;
}
.useBrowserlink {
margin-left: 5px;
margin-top: 20px;
span {
font-weight: 700;
font-size: $font-size-small;
}
}
.fido2-browser-selector-dropdown {
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
padding: 8px;
width: 100%;
box-shadow:
0 2px 2px 0 rgba(0, 0, 0, 0.14),
0 3px 1px -2px rgba(0, 0, 0, 0.12),
0 1px 5px 0 rgba(0, 0, 0, 0.2);
border-radius: $border-radius;
}
.fido2-browser-selector-dropdown-item {
@include themify($themes) {
color: themed("textColor") !important;
}
width: 100%;
text-align: left;
padding: 0px 15px 0px 5px;
margin-bottom: 5px;
border-radius: 3px;
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
&:hover {
@include themify($themes) {
background-color: themed("listItemBackgroundHoverColor") !important;
}
}
&:last-child {
margin-bottom: 0;
}
}
/** Temporary fix for avatar, will not be required once we migrate to tailwind preflight **/
bit-avatar svg {
display: block;
}

View File

@@ -1,23 +0,0 @@
@import "variables.scss";
@each $mfaType in $mfaTypes {
.mfaType#{$mfaType} {
content: url("../images/two-factor/" + $mfaType + ".png");
max-width: 100px;
}
}
.mfaType1 {
@include themify($themes) {
content: url("../images/two-factor/1" + themed("mfaLogoSuffix"));
max-width: 100px;
max-height: 45px;
}
}
.mfaType7 {
@include themify($themes) {
content: url("../images/two-factor/7" + themed("mfaLogoSuffix"));
max-width: 100px;
}
}

View File

@@ -1,13 +1,50 @@
@import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss";
@import "variables.scss";
@import "../../../../../libs/angular/src/scss/icons.scss";
@import "base.scss";
@import "grid.scss";
@import "box.scss";
@import "buttons.scss";
@import "misc.scss";
@import "environment.scss";
@import "pages.scss";
@import "plugins.scss";
@import "@angular/cdk/overlay-prebuilt.css";
@import "../../../../../libs/components/src/multi-select/scss/bw.theme";
.cdk-virtual-scroll-content-wrapper {
width: 100%;
}
// MFA Types for logo styling with no dark theme alternative
$mfaTypes: 0, 2, 3, 4, 6;
@each $mfaType in $mfaTypes {
.mfaType#{$mfaType} {
content: url("../images/two-factor/" + $mfaType + ".png");
max-width: 100px;
}
}
.mfaType0 {
content: url("../images/two-factor/0.png");
max-width: 100px;
max-height: 45px;
}
.mfaType1 {
max-width: 100px;
max-height: 45px;
&:is(.theme_light *) {
content: url("../images/two-factor/1.png");
}
&:is(.theme_dark *) {
content: url("../images/two-factor/1-w.png");
}
}
.mfaType7 {
max-width: 100px;
&:is(.theme_light *) {
content: url("../images/two-factor/7.png");
}
&:is(.theme_dark *) {
content: url("../images/two-factor/7-w.png");
}
}

View File

@@ -1,4 +1,104 @@
@import "../../../../../libs/components/src/tw-theme.css";
@import "../../../../../libs/components/src/tw-theme-preflight.css";
@layer base {
html {
overflow: hidden;
min-height: 600px;
height: 100%;
&.body-sm {
min-height: 500px;
}
&.body-xs {
min-height: 400px;
}
&.body-xxs {
min-height: 300px;
}
&.body-3xs {
min-height: 240px;
}
&.body-full {
min-height: unset;
width: 100%;
height: 100%;
& body {
width: 100%;
}
}
}
html.browser_safari {
&.safari_height_fix {
body {
height: 360px !important;
&.body-xs {
height: 300px !important;
}
&.body-full {
height: 100% !important;
}
}
}
app-root {
border-width: 1px;
border-style: solid;
border-color: #000000;
}
&.theme_light app-root {
border-color: #777777;
}
}
body {
width: 380px;
height: 100%;
position: relative;
min-height: inherit;
overflow: hidden;
@apply tw-bg-background-alt;
}
/**
* Workaround for slow performance on external monitors on Chrome + MacOS
* See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64
*/
@keyframes redraw {
0% {
opacity: 0.99;
}
100% {
opacity: 1;
}
}
html.force_redraw {
animation: redraw 1s linear infinite;
}
/**
* Text selection style:
* suppress user selection for most elements (to make it more app-like)
*/
h1,
h2,
h3,
label,
a,
button,
p,
img {
user-select: none;
}
}
@layer components {
/** Safari Support */
@@ -19,4 +119,59 @@
html:not(.browser_safari) .tw-styled-scrollbar {
scrollbar-color: rgb(var(--color-secondary-500)) rgb(var(--color-background-alt));
}
#duo-frame {
background: url("../images/loading.svg") 0 0 no-repeat;
width: 100%;
height: 470px;
margin-bottom: -10px;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
#web-authn-frame {
width: 100%;
height: 40px;
iframe {
border: none;
height: 100%;
width: 100%;
}
}
body.linux-webauthn {
width: 485px !important;
#web-authn-frame {
iframe {
width: 375px;
margin: 0 55px;
}
}
}
app-root > #loading {
display: flex;
text-align: center;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
@apply tw-text-muted;
}
/**
* Text selection style:
* Set explicit selection styles (assumes primary accent color has sufficient
* contrast against the background, so its inversion is also still readable)
*/
:not(bit-form-field input)::selection {
@apply tw-text-contrast;
@apply tw-bg-primary-700;
}
}

View File

@@ -1,178 +1,42 @@
$dark-icon-themes: "theme_dark";
/**
* DEPRECATED: DO NOT MODIFY OR USE!
*/
$dark-icon-themes: "theme_dark";
$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
$font-size-base: 16px;
$font-size-large: 18px;
$font-size-xlarge: 22px;
$font-size-xxlarge: 28px;
$font-size-small: 12px;
$text-color: #000000;
$border-color: #f0f0f0;
$border-color-dark: #ddd;
$list-item-hover: #fbfbfb;
$list-icon-color: #767679;
$disabled-box-opacity: 1;
$border-radius: 6px;
$line-height-base: 1.42857143;
$icon-hover-color: lighten($text-color, 50%);
$mfaTypes: 0, 2, 3, 4, 6;
$gray: #555;
$gray-light: #777;
$text-muted: $gray-light;
$brand-primary: #175ddc;
$brand-danger: #c83522;
$brand-success: #017e45;
$brand-info: #555555;
$brand-warning: #8b6609;
$brand-primary-accent: #1252a3;
$background-color: #f0f0f0;
$box-background-color: white;
$box-background-hover-color: $list-item-hover;
$box-border-color: $border-color;
$border-color-alt: #c3c5c7;
$button-border-color: darken($border-color-dark, 12%);
$button-background-color: white;
$button-color: lighten($text-color, 40%);
$button-color-primary: darken($brand-primary, 8%);
$button-color-danger: darken($brand-danger, 10%);
$code-color: #c01176;
$code-color-dark: #f08dc7;
$themes: (
light: (
textColor: $text-color,
hoverColorTransparent: rgba($text-color, 0.15),
borderColor: $border-color-dark,
backgroundColor: $background-color,
borderColorAlt: $border-color-alt,
backgroundColorAlt: #ffffff,
scrollbarColor: rgba(100, 100, 100, 0.2),
scrollbarHoverColor: rgba(100, 100, 100, 0.4),
boxBackgroundColor: $box-background-color,
boxBackgroundHoverColor: $box-background-hover-color,
boxBorderColor: $box-border-color,
tabBackgroundColor: #ffffff,
tabBackgroundHoverColor: $list-item-hover,
headerColor: #ffffff,
headerBackgroundColor: $brand-primary,
headerBackgroundHoverColor: rgba(255, 255, 255, 0.1),
headerBorderColor: $brand-primary,
headerInputBackgroundColor: darken($brand-primary, 8%),
headerInputBackgroundFocusColor: darken($brand-primary, 10%),
headerInputColor: #ffffff,
headerInputPlaceholderColor: lighten($brand-primary, 35%),
listItemBackgroundHoverColor: $list-item-hover,
disabledIconColor: $list-icon-color,
disabledBoxOpacity: $disabled-box-opacity,
headingColor: $gray-light,
labelColor: $gray-light,
mutedColor: $text-muted,
totpStrokeColor: $brand-primary,
boxRowButtonColor: $brand-primary,
boxRowButtonHoverColor: darken($brand-primary, 10%),
inputBorderColor: darken($border-color-dark, 7%),
inputBackgroundColor: #ffffff,
inputPlaceholderColor: lighten($gray-light, 35%),
buttonBackgroundColor: $button-background-color,
buttonBorderColor: $button-border-color,
buttonColor: $button-color,
buttonPrimaryColor: $button-color-primary,
buttonDangerColor: $button-color-danger,
primaryColor: $brand-primary,
primaryAccentColor: $brand-primary-accent,
dangerColor: $brand-danger,
successColor: $brand-success,
infoColor: $brand-info,
warningColor: $brand-warning,
logoSuffix: "dark",
mfaLogoSuffix: ".png",
passwordNumberColor: #007fde,
passwordSpecialColor: #c40800,
passwordCountText: #212529,
calloutBorderColor: $border-color-dark,
calloutBackgroundColor: $box-background-color,
toastTextColor: #ffffff,
svgSuffix: "-light.svg",
transparentColor: rgba(0, 0, 0, 0),
dateInputColorScheme: light,
// https://stackoverflow.com/a/53336754
webkitCalendarPickerFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
brightness(85%) contrast(103%),
// light has no hover so use same color
webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
brightness(85%) contrast(103%),
codeColor: $code-color,
),
dark: (
textColor: #ffffff,
hoverColorTransparent: rgba($text-color, 0.15),
borderColor: #161c26,
backgroundColor: #161c26,
borderColorAlt: #6e788a,
backgroundColorAlt: #2f343d,
scrollbarColor: #6e788a,
scrollbarHoverColor: #8d94a5,
boxBackgroundColor: #2f343d,
boxBackgroundHoverColor: #3c424e,
boxBorderColor: #4c525f,
tabBackgroundColor: #2f343d,
tabBackgroundHoverColor: #3c424e,
headerColor: #ffffff,
headerBackgroundColor: #2f343d,
headerBackgroundHoverColor: #3c424e,
headerBorderColor: #161c26,
headerInputBackgroundColor: #3c424e,
headerInputBackgroundFocusColor: #4c525f,
headerInputColor: #ffffff,
headerInputPlaceholderColor: #bac0ce,
listItemBackgroundHoverColor: #3c424e,
disabledIconColor: #bac0ce,
disabledBoxOpacity: 0.5,
headingColor: #bac0ce,
labelColor: #bac0ce,
mutedColor: #bac0ce,
totpStrokeColor: #4c525f,
boxRowButtonColor: #bac0ce,
boxRowButtonHoverColor: #ffffff,
inputBorderColor: #4c525f,
inputBackgroundColor: #2f343d,
inputPlaceholderColor: #bac0ce,
buttonBackgroundColor: #3c424e,
buttonBorderColor: #4c525f,
buttonColor: #bac0ce,
buttonPrimaryColor: #6f9df1,
buttonDangerColor: #ff8d85,
primaryColor: #6f9df1,
primaryAccentColor: #6f9df1,
dangerColor: #ff8d85,
successColor: #52e07c,
infoColor: #a4b0c6,
warningColor: #ffeb66,
logoSuffix: "white",
mfaLogoSuffix: "-w.png",
passwordNumberColor: #6f9df1,
passwordSpecialColor: #ff8d85,
passwordCountText: #ffffff,
calloutBorderColor: #4c525f,
calloutBackgroundColor: #3c424e,
toastTextColor: #1f242e,
svgSuffix: "-dark.svg",
transparentColor: rgba(0, 0, 0, 0),
dateInputColorScheme: dark,
// https://stackoverflow.com/a/53336754 - must prepend brightness(0) saturate(100%) to dark themed date inputs
webkitCalendarPickerFilter: brightness(0) saturate(100%) invert(86%) sepia(19%) saturate(152%)
hue-rotate(184deg) brightness(87%) contrast(93%),
webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%)
saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%),
codeColor: $code-color-dark,
),
);

View File

@@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core";
import { Component, input, ChangeDetectionStrategy } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute, Router } from "@angular/router";
@@ -25,31 +25,23 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach
import { AttachmentsV2Component } from "./attachments-v2.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupHeaderComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() backAction: () => void;
readonly pageTitle = input<string>();
readonly backAction = input<() => void>();
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-footer",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupFooterComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
readonly pageTitle = input<string>();
}
describe("AttachmentsV2Component", () => {
@@ -120,7 +112,7 @@ describe("AttachmentsV2Component", () => {
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
.componentInstance;
expect(cipherAttachment.submitBtn).toEqual(submitBtn);
expect(cipherAttachment.submitBtn()).toEqual(submitBtn);
});
it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => {

View File

@@ -1,5 +1,5 @@
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus" aria-hidden="true"></i>
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
<bit-menu #itemOptions>

View File

@@ -180,7 +180,7 @@ describe("VaultV2Component", () => {
const nudgesSvc = {
showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)),
dismissNudge: jest.fn().mockResolvedValue(undefined),
} as Partial<NudgesService>;
};
const dialogSvc = {} as Partial<DialogService>;
@@ -209,6 +209,10 @@ describe("VaultV2Component", () => {
.mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago
};
const configSvc = {
getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)),
};
beforeEach(async () => {
jest.clearAllMocks();
await TestBed.configureTestingModule({
@@ -256,9 +260,7 @@ describe("VaultV2Component", () => {
{ provide: StateProvider, useValue: mock<StateProvider>() },
{
provide: ConfigService,
useValue: {
getFeatureFlag$: (_: string) => of(false),
},
useValue: configSvc,
},
{
provide: SearchService,
@@ -453,7 +455,9 @@ describe("VaultV2Component", () => {
hasPremiumFromAnySource$.next(false);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) =>
configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(true));
nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) =>
of(type === NudgeType.PremiumUpgrade),
);
@@ -482,9 +486,11 @@ describe("VaultV2Component", () => {
}));
it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => {
configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(false));
itemsSvc.emptyVault$.next(true);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) => {
return of(type === NudgeType.EmptyVaultNudge);
});

View File

@@ -137,6 +137,10 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
FeatureFlag.VaultLoadingSkeletons,
);
protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.BrowserPremiumSpotlight,
);
private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe(
switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)),
);
@@ -164,6 +168,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
);
protected showPremiumSpotlight$ = combineLatest([
this.premiumSpotlightFeatureFlag$,
this.showPremiumNudgeSpotlight$,
this.showHasItemsVaultSpotlight$,
this.hasPremium$,
@@ -171,8 +176,13 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
this.accountAgeInDays$,
]).pipe(
map(
([showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) =>
showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && age >= 7,
([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) =>
featureFlagEnabled &&
showPremiumNudge &&
!showHasItemsNudge &&
!hasPremium &&
count >= 5 &&
age >= 7,
),
shareReplay({ bufferSize: 1, refCount: true }),
);

View File

@@ -12,5 +12,6 @@ config.content = [
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/pricing/src/**/*.{html,ts}",
];
config.corePlugins.preflight = true;
module.exports = config;

View File

@@ -186,15 +186,15 @@ export class EditCommand {
return Response.notFound();
}
let folderView = await folder.decrypt();
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
let folderView = await folder.decrypt(userKey);
folderView = FolderExport.toView(req, folderView);
const userKey = await this.keyService.getUserKey(activeUserId);
const encFolder = await this.folderService.encrypt(folderView, userKey);
try {
const folder = await this.folderApiService.save(encFolder, activeUserId);
const updatedFolder = new Folder(folder);
const decFolder = await updatedFolder.decrypt();
const decFolder = await updatedFolder.decrypt(userKey);
const res = new FolderResponse(decFolder);
return Response.success(res);
} catch (e) {

View File

@@ -417,10 +417,11 @@ export class GetCommand extends DownloadCommand {
private async getFolder(id: string) {
let decFolder: FolderView = null;
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
if (Utils.isGuid(id)) {
const folder = await this.folderService.getFromState(id, activeUserId);
if (folder != null) {
decFolder = await folder.decrypt();
decFolder = await folder.decrypt(userKey);
}
} else if (id.trim() !== "") {
let folders = await this.folderService.getAllDecryptedFromState(activeUserId);

View File

@@ -3,6 +3,8 @@ import * as sdk from "@bitwarden/sdk-internal";
export class CliSdkLoadService extends SdkLoadService {
async load(): Promise<void> {
// CLI uses stdout for user interaction / automations so we cannot log info / debug here.
SdkLoadService.logLevel = sdk.LogLevel.Error;
const module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm");
(sdk as any).init(module);
}

View File

@@ -181,12 +181,12 @@ export class CreateCommand {
private async createFolder(req: FolderExport) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const userKey = await this.keyService.getUserKey(activeUserId);
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
const folder = await this.folderService.encrypt(FolderExport.toView(req), userKey);
try {
const folderData = await this.folderApiService.save(folder, activeUserId);
const newFolder = new Folder(folderData);
const decFolder = await newFolder.decrypt();
const decFolder = await newFolder.decrypt(userKey);
const res = new FolderResponse(decFolder);
return Response.success(res);
} catch (e) {

View File

@@ -2,21 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
@@ -347,23 +332,8 @@ dependencies = [
"mockall",
"serial_test",
"tracing",
"windows 0.61.1",
"windows-core 0.61.0",
]
[[package]]
name = "backtrace"
version = "0.3.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets 0.52.6",
"windows",
"windows-core",
]
[[package]]
@@ -457,7 +427,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -501,6 +471,12 @@ dependencies = [
"cipher",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -509,9 +485,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "camino"
@@ -556,9 +532,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.46"
version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -623,7 +599,7 @@ dependencies = [
"tokio",
"tracing",
"verifysign",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -709,9 +685,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.6.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
@@ -770,16 +746,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "ctor"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "ctor"
version = "0.5.0"
@@ -877,13 +843,13 @@ dependencies = [
"sha2",
"ssh-key",
"sysinfo",
"thiserror 2.0.12",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
"typenum",
"widestring",
"windows 0.61.1",
"windows",
"windows-future",
"zbus",
"zbus_polkit",
@@ -1409,17 +1375,11 @@ dependencies = [
"polyval",
]
[[package]]
name = "gimli"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "glob"
version = "0.3.2"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "goblin"
@@ -1499,14 +1459,14 @@ dependencies = [
[[package]]
name = "homedir"
version = "0.3.4"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2"
checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527"
dependencies = [
"cfg-if",
"nix 0.29.0",
"nix",
"widestring",
"windows 0.57.0",
"windows",
]
[[package]]
@@ -1663,6 +1623,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1674,9 +1644,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "libloading"
@@ -1841,15 +1811,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "1.0.3"
@@ -1889,32 +1850,33 @@ dependencies = [
[[package]]
name = "napi"
version = "2.16.17"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
checksum = "f1b74e3dce5230795bb4d2821b941706dee733c7308752507254b0497f39cad7"
dependencies = [
"bitflags",
"ctor 0.2.9",
"napi-derive",
"ctor",
"napi-build",
"napi-sys",
"once_cell",
"nohash-hasher",
"rustc-hash",
"tokio",
]
[[package]]
name = "napi-build"
version = "2.2.0"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03acbfa4f156a32188bfa09b86dc11a431b5725253fc1fc6f6df5bed273382c4"
checksum = "dcae8ad5609d14afb3a3b91dee88c757016261b151e9dcecabf1b2a31a6cab14"
[[package]]
name = "napi-derive"
version = "2.16.13"
version = "3.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
checksum = "7552d5a579b834614bbd496db5109f1b9f1c758f08224b0dee1e408333adf0d0"
dependencies = [
"cfg-if",
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
@@ -1923,40 +1885,26 @@ dependencies = [
[[package]]
name = "napi-derive-backend"
version = "1.0.75"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
checksum = "5f6a81ac7486b70f2532a289603340862c06eea5a1e650c1ffeda2ce1238516a"
dependencies = [
"convert_case",
"once_cell",
"proc-macro2",
"quote",
"regex",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "2.4.0"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
checksum = "3e4e7135a8f97aa0f1509cce21a8a1f9dcec1b50d8dee006b48a5adb69a9d64d"
dependencies = [
"libloading",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nix"
version = "0.30.1"
@@ -1970,6 +1918,12 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "7.1.3"
@@ -2173,15 +2127,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -2548,7 +2493,7 @@ dependencies = [
name = "process_isolation"
version = "0.0.0"
dependencies = [
"ctor 0.5.0",
"ctor",
"desktop_core",
"libc",
"tracing",
@@ -2660,19 +2605,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.12",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
"thiserror 2.0.17",
]
[[package]]
@@ -2748,10 +2681,10 @@ dependencies = [
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
@@ -2798,6 +2731,12 @@ dependencies = [
"rustix 1.0.7",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
@@ -2870,15 +2809,15 @@ dependencies = [
"libc",
"rustix 1.0.7",
"rustix-linux-procfs",
"thiserror 2.0.12",
"windows 0.61.1",
"thiserror 2.0.17",
"windows",
]
[[package]]
name = "security-framework"
version = "3.5.0"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags",
"core-foundation",
@@ -3068,12 +3007,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.5.9"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3188,16 +3127,16 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.35.0"
version = "0.37.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b897c8ea620e181c7955369a31be5f48d9a9121cb59fd33ecef9ff2a34323422"
checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -3239,11 +3178,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.12"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl 2.0.12",
"thiserror-impl 2.0.17",
]
[[package]]
@@ -3259,9 +3198,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.12"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
@@ -3289,11 +3228,10 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.45.0"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
@@ -3303,14 +3241,14 @@ dependencies = [
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -3319,9 +3257,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.13"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
@@ -3680,6 +3618,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -3745,6 +3694,51 @@ dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wayland-backend"
version = "0.3.10"
@@ -3852,16 +3846,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.1"
@@ -3869,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
dependencies = [
"windows-collections",
"windows-core 0.61.0",
"windows-core",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
@@ -3881,19 +3865,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.0",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
"windows-core",
]
[[package]]
@@ -3902,8 +3874,8 @@ version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement 0.60.0",
"windows-interface 0.59.1",
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -3915,21 +3887,10 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [
"windows-core 0.61.0",
"windows-core",
"windows-link 0.1.3",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
@@ -3941,17 +3902,6 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
@@ -3981,7 +3931,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.0",
"windows-core",
"windows-link 0.1.3",
]
@@ -3996,15 +3946,6 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4262,8 +4203,8 @@ name = "windows_plugin_authenticator"
version = "0.0.0"
dependencies = [
"hex",
"windows 0.61.1",
"windows-core 0.61.0",
"windows",
"windows-core",
]
[[package]]
@@ -4434,9 +4375,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7"
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-executor",
@@ -4452,14 +4393,15 @@ dependencies = [
"futures-core",
"futures-lite",
"hex",
"nix 0.30.1",
"nix",
"ordered-stream",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"windows-sys 0.60.2",
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
@@ -4468,9 +4410,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca"
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
dependencies = [
"proc-macro-crate",
"proc-macro2",

View File

@@ -27,7 +27,7 @@ ashpd = "=0.11.0"
base64 = "=0.22.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" }
byteorder = "=1.5.0"
bytes = "=1.10.1"
bytes = "=1.11.0"
cbc = "=0.1.2"
chacha20poly1305 = "=0.10.1"
core-foundation = "=0.10.1"
@@ -37,14 +37,14 @@ ed25519 = "=2.2.3"
embed_plist = "=1.2.2"
futures = "=0.3.31"
hex = "=0.4.3"
homedir = "=0.3.4"
homedir = "=0.3.6"
interprocess = "=2.2.1"
libc = "=0.2.177"
libc = "=0.2.178"
linux-keyutils = "=0.2.4"
memsec = "=0.7.0"
napi = "=2.16.17"
napi-build = "=2.2.0"
napi-derive = "=2.16.13"
napi = "=3.3.0"
napi-build = "=2.2.3"
napi-derive = "=3.2.5"
oo7 = "=0.4.3"
pin-project = "=1.1.10"
pkcs8 = "=0.10.2"
@@ -53,17 +53,17 @@ rsa = "=0.9.6"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
secmem-proc = "=0.3.7"
security-framework = "=3.5.0"
security-framework = "=3.5.1"
security-framework-sys = "=2.15.0"
serde = "=1.0.209"
serde_json = "=1.0.127"
sha2 = "=0.10.8"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.35.0"
thiserror = "=2.0.12"
tokio = "=1.45.0"
tokio-util = "=0.7.13"
sysinfo = "=0.37.2"
thiserror = "=2.0.17"
tokio = "=1.48.0"
tokio-util = "=0.7.17"
tracing = "=0.1.41"
tracing-subscriber = { version = "=0.3.20", features = [
"fmt",
@@ -77,7 +77,7 @@ windows = "=0.61.1"
windows-core = "=0.61.0"
windows-future = "=0.2.0"
windows-registry = "=0.6.1"
zbus = "=5.11.0"
zbus = "=5.12.0"
zbus_polkit = "=5.0.0"
zeroizing-alloc = "=0.1.0"

View File

@@ -11,8 +11,8 @@ const rustTargetsMap = {
"aarch64-pc-windows-msvc": { nodeArch: 'arm64', platform: 'win32' },
"x86_64-apple-darwin": { nodeArch: 'x64', platform: 'darwin' },
"aarch64-apple-darwin": { nodeArch: 'arm64', platform: 'darwin' },
'x86_64-unknown-linux-musl': { nodeArch: 'x64', platform: 'linux' },
'aarch64-unknown-linux-musl': { nodeArch: 'arm64', platform: 'linux' },
'x86_64-unknown-linux-gnu': { nodeArch: 'x64', platform: 'linux' },
'aarch64-unknown-linux-gnu': { nodeArch: 'arm64', platform: 'linux' },
}
// Ensure the dist directory exists

View File

@@ -7,9 +7,9 @@ pub struct NativeImporterMetadata {
/// Identifies the importer
pub id: String,
/// Describes the strategies used to obtain imported data
pub loaders: Vec<&'static str>,
pub loaders: Vec<String>,
/// Identifies the instructions for the importer
pub instructions: &'static str,
pub instructions: String,
}
/// Returns a map of supported importers based on the current platform.
@@ -36,9 +36,9 @@ pub fn get_supported_importers<T: InstalledBrowserRetriever>(
PLATFORM_SUPPORTED_BROWSERS.iter().map(|b| b.name).collect();
for (id, browser_name) in IMPORTERS {
let mut loaders: Vec<&'static str> = vec!["file"];
let mut loaders: Vec<String> = vec!["file".to_string()];
if supported.contains(browser_name) {
loaders.push("chromium");
loaders.push("chromium".to_string());
}
if installed_browsers.contains(&browser_name.to_string()) {
@@ -47,7 +47,7 @@ pub fn get_supported_importers<T: InstalledBrowserRetriever>(
NativeImporterMetadata {
id: id.to_string(),
loaders,
instructions: "chromium",
instructions: "chromium".to_string(),
},
);
}
@@ -79,12 +79,9 @@ mod tests {
map.keys().cloned().collect()
}
fn get_loaders(
map: &HashMap<String, NativeImporterMetadata>,
id: &str,
) -> HashSet<&'static str> {
fn get_loaders(map: &HashMap<String, NativeImporterMetadata>, id: &str) -> HashSet<String> {
map.get(id)
.map(|m| m.loaders.iter().copied().collect::<HashSet<_>>())
.map(|m| m.loaders.iter().cloned().collect::<HashSet<_>>())
.unwrap_or_default()
}
@@ -107,7 +104,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.contains(&"file"));
assert!(meta.loaders.contains(&"file".to_owned()));
}
}
@@ -147,7 +144,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.contains(&"file"));
assert!(meta.loaders.contains(&"file".to_owned()));
}
}
@@ -183,7 +180,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.contains(&"file"));
assert!(meta.loaders.contains(&"file".to_owned()));
}
}

View File

@@ -285,8 +285,8 @@ async fn windows_hello_authenticate_with_crypto(
return Err(anyhow!("Failed to sign data"));
}
let signature_buffer = signature.Result()?;
let signature_value = unsafe { as_mut_bytes(&signature_buffer)? };
let mut signature_buffer = signature.Result()?;
let signature_value = unsafe { as_mut_bytes(&mut signature_buffer)? };
// The signature is deterministic based on the challenge and keychain key. Thus, it can be
// hashed to a key. It is unclear what entropy this key provides.
@@ -368,7 +368,7 @@ fn decrypt_data(
Ok(plaintext)
}
unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> {
unsafe fn as_mut_bytes(buffer: &mut IBuffer) -> Result<&mut [u8]> {
let interop = buffer.cast::<IBufferByteAccess>()?;
unsafe {

View File

@@ -24,7 +24,7 @@ serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-oslog = "0.3.0"
tracing-oslog = "=0.3.0"
[build-dependencies]
uniffi = { workspace = true, features = ["build"] }

View File

@@ -0,0 +1,35 @@
# 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.
## The high level
MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys).
Weve written a Swift-based native autofill-extension. Its bundled in the app-bundle in PlugIns, similar to the safari-extension.
This swift extension currently communicates with our Electron app through IPC based on a unix socket. The IPC implementation is done in Rust and utilized through UniFFI + NAPI bindings.
Footnotes:
* We're not using the IPC framework as the implementation pre-dates the IPC framework.
* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed.
Electron receives the messages and passes it to Angular (through the electron-renderer event system).
Our existing fido2 services in the renderer respond to events, displaying UI as necessary, and returns the signature back through the same mechanism, allowing people to authenticate with passkeys through the native system + UI. See [Mac OS Native Passkey Workflows](https://bitwarden.atlassian.net/wiki/spaces/EN/pages/1828356098/Mac+OS+Native+Passkey+Workflows) for demo videos.
## Typescript + UI implementations
We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ui environments' in mind.
Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors.
Weve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app.
## Modal mode
When modal mode is activated, the desktop app turns into a smaller modal that is always on top and cannot be resized. This is done to improve the UX of performing a passkey operation (or SSH operation). Once the operation is completed, the app returns to normal mode and its previous position.
We are not using electron modal windows, for a couple reason. It would require us to send data in yet another layer of IPC, but also because we'd need to bootstrap entire renderer/app instead of reusing the existing window.
Some modal modes may hide the 'traffic buttons' (window controls) due to design requirements.

View File

@@ -8,6 +8,9 @@ rm -r tmp
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

View File

@@ -57,6 +57,14 @@ trait Callback: Send + Sync {
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>,
@@ -65,8 +73,24 @@ pub struct MacOSProviderClient {
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.
@@ -93,13 +117,16 @@ impl MacOSProviderClient {
let client = MacOSProviderClient {
to_server_send,
response_callbacks_counter: AtomicU32::new(0),
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()
@@ -117,9 +144,11 @@ impl MacOSProviderClient {
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,
@@ -157,12 +186,17 @@ impl MacOSProviderClient {
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, Box::new(callback));
self.send_message(request, Some(Box::new(callback)));
}
pub fn prepare_passkey_assertion(
@@ -170,7 +204,7 @@ impl MacOSProviderClient {
request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Box::new(callback));
self.send_message(request, Some(Box::new(callback)));
}
pub fn prepare_passkey_assertion_without_user_interface(
@@ -178,7 +212,18 @@ impl MacOSProviderClient {
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Box::new(callback));
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
}
}
}
@@ -200,7 +245,6 @@ enum SerializedMessage {
}
impl MacOSProviderClient {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
let sequence_number = self
@@ -209,20 +253,23 @@ impl MacOSProviderClient {
self.response_callbacks_queue
.lock()
.unwrap()
.expect("response callbacks queue mutex should not be poisoned")
.insert(sequence_number, (callback, Instant::now()));
sequence_number
}
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
fn send_message(
&self,
message: impl Serialize + DeserializeOwned,
callback: Box<dyn Callback>,
callback: Option<Box<dyn Callback>>,
) {
let sequence_number = self.add_callback(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,
@@ -232,16 +279,18 @@ impl MacOSProviderClient {
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 let Some((cb, _)) = self
if sequence_number != NO_CALLBACK_INDICATOR {
if let Some((callback, _)) = self
.response_callbacks_queue
.lock()
.unwrap()
.expect("response callbacks queue mutex should not be poisoned")
.remove(&sequence_number)
{
cb.error(BitwardenError::Internal(format!(
callback.error(BitwardenError::Internal(format!(
"Error sending message: {e}"
)));
}
}
}
}
}

View File

@@ -14,6 +14,7 @@ pub struct PasskeyRegistrationRequest {
user_verification: UserVerification,
supported_algorithms: Vec<i32>,
window_xy: Position,
excluded_credentials: Vec<Vec<u8>>,
}
#[derive(uniffi::Record, Serialize, Deserialize)]

View File

@@ -1,125 +1,7 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export declare namespace passwords {
/** The error message returned when a password is not found during retrieval or deletion. */
export const PASSWORD_NOT_FOUND: string
/**
* Fetch the stored password from the keychain.
* Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
*/
export function getPassword(service: string, account: string): Promise<string>
/**
* Save the password to the keychain. Adds an entry if none exists otherwise updates the
* existing entry.
*/
export function setPassword(service: string, account: string, password: string): Promise<void>
/**
* Delete the stored password from the keychain.
* Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
*/
export function deletePassword(service: string, account: string): Promise<void>
/** Checks if the os secure storage is available */
export function isAvailable(): Promise<boolean>
}
export declare namespace biometrics {
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
export function available(): Promise<boolean>
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<string>
/**
* Retrieves the biometric secret for the given service and account.
* Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
*/
export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise<string>
/**
* Derives key material from biometric data. Returns a string encoded with a
* base64 encoded key and the base64 encoded challenge used to create it
* separated by a `|` character.
*
* If the iv is provided, it will be used as the challenge. Otherwise a random challenge will
* be generated.
*
* `format!("<key_base64>|<iv_base64>")`
*/
export function deriveKeyMaterial(iv?: string | undefined | null): Promise<OsDerivedKey>
export interface KeyMaterial {
osKeyPartB64: string
clientKeyPartB64?: string
}
export interface OsDerivedKey {
keyB64: string
ivB64: string
}
}
export declare namespace biometrics_v2 {
export function initBiometricSystem(): BiometricLockSystem
export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise<boolean>
export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise<boolean>
export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise<Buffer>
export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise<void>
export class BiometricLockSystem { }
}
export declare namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>
}
export declare namespace sshagent {
export interface PrivateKey {
privateKey: string
name: string
cipherId: string
}
export interface SshKey {
privateKey: string
publicKey: string
keyFingerprint: string
}
export interface SshUiRequest {
cipherId?: string
isList: boolean
processName: string
isForwarding: boolean
namespace?: string
}
export function serve(callback: (err: Error | null, arg: SshUiRequest) => any): Promise<SshAgentState>
export function stop(agentState: SshAgentState): void
export function isRunning(agentState: SshAgentState): boolean
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
export function lock(agentState: SshAgentState): void
export function clearKeys(agentState: SshAgentState): void
export class SshAgentState { }
}
export declare namespace processisolations {
export function disableCoredumps(): Promise<void>
export function isCoreDumpingDisabled(): Promise<boolean>
export function isolateProcess(): Promise<void>
}
export declare namespace powermonitors {
export function onLock(callback: (err: Error | null, ) => any): Promise<void>
export function isLockMonitorAvailable(): Promise<boolean>
}
export declare namespace windows_registry {
export function createKey(key: string, subkey: string, value: string): Promise<void>
export function deleteKey(key: string, subkey: string): Promise<void>
}
export declare namespace ipc {
export interface IpcMessage {
clientId: number
kind: IpcMessageType
message?: string
}
export const enum IpcMessageType {
Connected = 0,
Disconnected = 1,
Message = 2
}
export class IpcServer {
/* eslint-disable */
export declare namespace autofill {
export class AutofillIpcServer {
/**
* Create and start the IPC server without blocking.
*
@@ -127,49 +9,18 @@ export declare namespace ipc {
* connection and must be the same for both the server and client. @param callback
* This function will be called whenever a message is received from a client.
*/
static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise<IpcServer>
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise<AutofillIpcServer>
/** Return the path to the IPC server. */
getPath(): string
/** Stop the IPC server. */
stop(): void
/**
* Send a message over the IPC server to all the connected clients
*
* @return The number of clients that the message was sent to. Note that the number of
* messages actually received may be less, as some clients could disconnect before
* receiving the message.
*/
send(message: string): number
completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number
completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number
completeError(clientId: number, sequenceNumber: number, error: string): number
}
}
export declare namespace autostart {
export function setAutostart(autostart: boolean, params: Array<string>): Promise<void>
}
export declare namespace autofill {
export function runCommand(value: string): Promise<string>
export const enum UserVerification {
Preferred = 'preferred',
Required = 'required',
Discouraged = 'discouraged'
}
export interface Position {
x: number
y: number
}
export interface PasskeyRegistrationRequest {
rpId: string
userName: string
userHandle: Array<number>
clientDataHash: Array<number>
userVerification: UserVerification
supportedAlgorithms: Array<number>
windowXy: Position
}
export interface PasskeyRegistrationResponse {
rpId: string
clientDataHash: Array<number>
credentialId: Array<number>
attestationObject: Array<number>
export interface NativeStatus {
key: string
value: string
}
export interface PasskeyAssertionRequest {
rpId: string
@@ -178,6 +29,14 @@ export declare namespace autofill {
allowedCredentials: Array<Array<number>>
windowXy: Position
}
export interface PasskeyAssertionResponse {
rpId: string
userHandle: Array<number>
signature: Array<number>
clientDataHash: Array<number>
authenticatorData: Array<number>
credentialId: Array<number>
}
export interface PasskeyAssertionWithoutUserInterfaceRequest {
rpId: string
credentialId: Array<number>
@@ -188,50 +47,93 @@ export declare namespace autofill {
userVerification: UserVerification
windowXy: Position
}
export interface PasskeyAssertionResponse {
export interface PasskeyRegistrationRequest {
rpId: string
userName: string
userHandle: Array<number>
signature: Array<number>
clientDataHash: Array<number>
authenticatorData: Array<number>
userVerification: UserVerification
supportedAlgorithms: Array<number>
windowXy: Position
excludedCredentials: Array<Array<number>>
}
export interface PasskeyRegistrationResponse {
rpId: string
clientDataHash: Array<number>
credentialId: Array<number>
attestationObject: Array<number>
}
export class IpcServer {
export interface Position {
x: number
y: number
}
export function runCommand(value: string): Promise<string>
export const enum UserVerification {
Preferred = 'preferred',
Required = 'required',
Discouraged = 'discouraged'
}
}
export declare namespace autostart {
export function setAutostart(autostart: boolean, params: Array<string>): Promise<void>
}
export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>, keyboardShortcut: Array<string>): void
}
export declare namespace biometrics {
export function available(): Promise<boolean>
/**
* Create and start the IPC server without blocking.
* Derives key material from biometric data. Returns a string encoded with a
* base64 encoded key and the base64 encoded challenge used to create it
* separated by a `|` character.
*
* @param name The endpoint name to listen on. This name uniquely identifies the IPC
* connection and must be the same for both the server and client. @param callback
* This function will be called whenever a message is received from a client.
* If the iv is provided, it will be used as the challenge. Otherwise a random challenge will
* be generated.
*
* `format!("<key_base64>|<iv_base64>")`
*/
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise<IpcServer>
/** Return the path to the IPC server. */
getPath(): string
/** Stop the IPC server. */
stop(): void
completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number
completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number
completeError(clientId: number, sequenceNumber: number, error: string): number
export function deriveKeyMaterial(iv?: string | undefined | null): Promise<OsDerivedKey>
/**
* Retrieves the biometric secret for the given service and account.
* Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist.
*/
export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise<string>
export interface KeyMaterial {
osKeyPartB64: string
clientKeyPartB64?: string
}
export interface OsDerivedKey {
keyB64: string
ivB64: string
}
export declare namespace passkey_authenticator {
export function register(): void
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<string>
}
export declare namespace logging {
export const enum LogLevel {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4
export declare namespace biometrics_v2 {
export class BiometricLockSystem {
}
export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void
export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise<boolean>
export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise<boolean>
export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function initBiometricSystem(): BiometricLockSystem
export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise<void>
export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise<Buffer>
export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
}
export declare namespace chromium_importer {
export interface ProfileInfo {
id: string
name: string
}
export function getAvailableProfiles(browser: string): Array<ProfileInfo>
/** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */
export function getMetadata(): Record<string, NativeImporterMetadata>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
export interface Login {
url: string
username: string
@@ -252,12 +154,130 @@ export declare namespace chromium_importer {
loaders: Array<string>
instructions: string
}
/** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */
export function getMetadata(): Record<string, NativeImporterMetadata>
export function getAvailableProfiles(browser: string): Array<ProfileInfo>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
export interface ProfileInfo {
id: string
name: string
}
export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>, keyboardShortcut: Array<string>): void
}
export declare namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>
}
export declare namespace ipc {
export class NativeIpcServer {
/**
* Create and start the IPC server without blocking.
*
* @param name The endpoint name to listen on. This name uniquely identifies the IPC
* connection and must be the same for both the server and client. @param callback
* This function will be called whenever a message is received from a client.
*/
static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise<NativeIpcServer>
/** Return the path to the IPC server. */
getPath(): string
/** Stop the IPC server. */
stop(): void
/**
* Send a message over the IPC server to all the connected clients
*
* @return The number of clients that the message was sent to. Note that the number of
* messages actually received may be less, as some clients could disconnect before
* receiving the message.
*/
send(message: string): number
}
export interface IpcMessage {
clientId: number
kind: IpcMessageType
message?: string
}
export const enum IpcMessageType {
Connected = 0,
Disconnected = 1,
Message = 2
}
}
export declare namespace logging {
export function initNapiLog(jsLogFn: ((err: Error | null, arg0: LogLevel, arg1: string) => any)): void
export const enum LogLevel {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4
}
}
export declare namespace passkey_authenticator {
export function register(): void
}
export declare namespace passwords {
/**
* Delete the stored password from the keychain.
* Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
*/
export function deletePassword(service: string, account: string): Promise<void>
/**
* Fetch the stored password from the keychain.
* Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist.
*/
export function getPassword(service: string, account: string): Promise<string>
/** Checks if the os secure storage is available */
export function isAvailable(): Promise<boolean>
/** The error message returned when a password is not found during retrieval or deletion. */
export const PASSWORD_NOT_FOUND: string
/**
* Save the password to the keychain. Adds an entry if none exists otherwise updates the
* existing entry.
*/
export function setPassword(service: string, account: string, password: string): Promise<void>
}
export declare namespace powermonitors {
export function isLockMonitorAvailable(): Promise<boolean>
export function onLock(callback: ((err: Error | null, ) => any)): Promise<void>
}
export declare namespace processisolations {
export function disableCoredumps(): Promise<void>
export function isCoreDumpingDisabled(): Promise<boolean>
export function isolateProcess(): Promise<void>
}
export declare namespace sshagent {
export class SshAgentState {
}
export function clearKeys(agentState: SshAgentState): void
export function isRunning(agentState: SshAgentState): boolean
export function lock(agentState: SshAgentState): void
export interface PrivateKey {
privateKey: string
name: string
cipherId: string
}
export function serve(callback: ((err: Error | null, arg: SshUiRequest) => Promise<boolean>)): Promise<SshAgentState>
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
export interface SshKey {
privateKey: string
publicKey: string
keyFingerprint: string
}
export interface SshUiRequest {
cipherId?: string
isList: boolean
processName: string
isForwarding: boolean
namespace?: string
}
export function stop(agentState: SshAgentState): void
}
export declare namespace windows_registry {
export function createKey(key: string, subkey: string, value: string): Promise<void>
export function deleteKey(key: string, subkey: string): Promise<void>
}

View File

@@ -82,20 +82,20 @@ switch (platform) {
switch (arch) {
case "x64":
nativeBinding = loadFirstAvailable(
["desktop_napi.linux-x64-musl.node", "desktop_napi.linux-x64-gnu.node"],
"@bitwarden/desktop-napi-linux-x64-musl",
["desktop_napi.linux-x64-gnu.node"],
"@bitwarden/desktop-napi-linux-x64-gnu",
);
break;
case "arm64":
nativeBinding = loadFirstAvailable(
["desktop_napi.linux-arm64-musl.node", "desktop_napi.linux-arm64-gnu.node"],
"@bitwarden/desktop-napi-linux-arm64-musl",
["desktop_napi.linux-arm64-gnu.node"],
"@bitwarden/desktop-napi-linux-arm64-gnu",
);
break;
case "arm":
nativeBinding = loadFirstAvailable(
["desktop_napi.linux-arm-musl.node", "desktop_napi.linux-arm-gnu.node"],
"@bitwarden/desktop-napi-linux-arm-musl",
["desktop_napi.linux-arm-gnu.node"],
"@bitwarden/desktop-napi-linux-arm-gnu",
);
localFileExisted = existsSync(join(__dirname, "desktop_napi.linux-arm-gnueabihf.node"));
try {

View File

@@ -9,21 +9,17 @@
"author": "",
"license": "GPL-3.0",
"devDependencies": {
"@napi-rs/cli": "2.18.4"
"@napi-rs/cli": "3.2.0"
},
"napi": {
"name": "desktop_napi",
"triples": {
"defaults": true,
"additional": [
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"i686-pc-windows-msvc",
"armv7-unknown-linux-gnueabihf",
"binaryName": "desktop_napi",
"targets": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-musl",
"aarch64-pc-windows-msvc"
"aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"armv7-unknown-linux-gnueabihf",
"i686-pc-windows-msvc",
"x86_64-unknown-linux-gnu"
]
}
}
}

View File

@@ -10,12 +10,12 @@ const args = args.join(' ');
if (isRelease) {
console.log('Building release mode.');
execSync(`napi build --platform --js false ${args}`, { stdio: 'inherit'});
execSync(`napi build --platform --no-js ${args}`, { stdio: 'inherit'});
} else {
console.log('Building debug mode.');
execSync(`napi build --platform --js false ${args}`, {
execSync(`napi build --platform --no-js ${args}`, {
stdio: 'inherit',
env: { ...process.env, RUST_LOG: 'debug' }
});

View File

@@ -290,7 +290,7 @@ pub mod sshagent {
use napi::{
bindgen_prelude::Promise,
threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use tokio::{self, sync::Mutex};
use tracing::error;
@@ -326,13 +326,15 @@ pub mod sshagent {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
#[napi]
pub async fn serve(
callback: ThreadsafeFunction<SshUIRequest, CalleeHandled>,
callback: ThreadsafeFunction<SshUIRequest, Promise<bool>>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<desktop_core::ssh_agent::SshAgentUIRequest>(32);
let (auth_response_tx, auth_response_rx) =
tokio::sync::broadcast::channel::<(u32, bool)>(32);
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
// Wrap callback in Arc so it can be shared across spawned tasks
let callback = Arc::new(callback);
tokio::spawn(async move {
let _ = auth_response_rx;
@@ -342,42 +344,50 @@ pub mod sshagent {
tokio::spawn(async move {
let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback;
let promise_result: Result<Promise<bool>, napi::Error> = callback
.call_async(Ok(SshUIRequest {
// In NAPI v3, obtain the JS callback return as a Promise<boolean> and await it
// in Rust
let (tx, rx) = std::sync::mpsc::channel::<Promise<bool>>();
let status = callback.call_with_return_value(
Ok(SshUIRequest {
cipher_id: request.cipher_id,
is_list: request.is_list,
process_name: request.process_name,
is_forwarding: request.is_forwarding,
namespace: request.namespace,
}))
.await;
match promise_result {
Ok(promise_result) => match promise_result.await {
Ok(result) => {
}),
ThreadsafeFunctionCallMode::Blocking,
move |ret: Result<Promise<bool>, napi::Error>, _env| {
if let Ok(p) = ret {
let _ = tx.send(p);
}
Ok(())
},
);
let result = if status == napi::Status::Ok {
match rx.recv() {
Ok(promise) => match promise.await {
Ok(v) => v,
Err(e) => {
error!(error = %e, "UI callback promise rejected");
false
}
},
Err(e) => {
error!(error = %e, "Failed to receive UI callback promise");
false
}
}
} else {
error!(error = ?status, "Calling UI callback failed");
false
};
let _ = auth_response_tx_arc
.lock()
.await
.send((request.request_id, result))
.expect("should be able to send auth response to agent");
}
Err(e) => {
error!(error = %e, "Calling UI callback promise was rejected");
let _ = auth_response_tx_arc
.lock()
.await
.send((request.request_id, false))
.expect("should be able to send auth response to agent");
}
},
Err(e) => {
error!(error = %e, "Calling UI callback could not create promise");
let _ = auth_response_tx_arc
.lock()
.await
.send((request.request_id, false))
.expect("should be able to send auth response to agent");
}
}
});
}
});
@@ -465,14 +475,12 @@ pub mod processisolations {
#[napi]
pub mod powermonitors {
use napi::{
threadsafe_function::{
ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode,
},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
tokio,
};
#[napi]
pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> {
pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
desktop_core::powermonitor::on_lock(tx)
.await
@@ -511,9 +519,7 @@ pub mod windows_registry {
#[napi]
pub mod ipc {
use desktop_core::ipc::server::{Message, MessageType};
use napi::threadsafe_function::{
ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode,
};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
#[napi(object)]
pub struct IpcMessage {
@@ -550,12 +556,12 @@ pub mod ipc {
}
#[napi]
pub struct IpcServer {
pub struct NativeIpcServer {
server: desktop_core::ipc::server::Server,
}
#[napi]
impl IpcServer {
impl NativeIpcServer {
/// Create and start the IPC server without blocking.
///
/// @param name The endpoint name to listen on. This name uniquely identifies the IPC
@@ -566,7 +572,7 @@ pub mod ipc {
pub async fn listen(
name: String,
#[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")]
callback: ThreadsafeFunction<IpcMessage, ErrorStrategy::CalleeHandled>,
callback: ThreadsafeFunction<IpcMessage>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
@@ -583,7 +589,7 @@ pub mod ipc {
))
})?;
Ok(IpcServer { server })
Ok(NativeIpcServer { server })
}
/// Return the path to the IPC server.
@@ -630,8 +636,9 @@ pub mod autostart {
#[napi]
pub mod autofill {
use desktop_core::ipc::server::{Message, MessageType};
use napi::threadsafe_function::{
ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode,
use napi::{
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::error;
@@ -686,6 +693,7 @@ pub mod autofill {
pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>,
pub window_xy: Position,
pub excluded_credentials: Vec<Vec<u8>>,
}
#[napi(object)]
@@ -724,6 +732,14 @@ pub mod autofill {
pub window_xy: Position,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeStatus {
pub key: String,
pub value: String,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -737,14 +753,14 @@ pub mod autofill {
}
#[napi]
pub struct IpcServer {
pub struct AutofillIpcServer {
server: desktop_core::ipc::server::Server,
}
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
#[napi]
impl IpcServer {
impl AutofillIpcServer {
/// Create and start the IPC server without blocking.
///
/// @param name The endpoint name to listen on. This name uniquely identifies the IPC
@@ -760,23 +776,24 @@ pub mod autofill {
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void"
)]
registration_callback: ThreadsafeFunction<
(u32, u32, PasskeyRegistrationRequest),
ErrorStrategy::CalleeHandled,
FnArgs<(u32, u32, PasskeyRegistrationRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void"
)]
assertion_callback: ThreadsafeFunction<
(u32, u32, PasskeyAssertionRequest),
ErrorStrategy::CalleeHandled,
FnArgs<(u32, u32, PasskeyAssertionRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
)]
assertion_without_user_interface_callback: ThreadsafeFunction<
(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest),
ErrorStrategy::CalleeHandled,
FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void"
)]
native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
@@ -801,7 +818,7 @@ pub mod autofill {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_callback
@@ -820,7 +837,7 @@ pub mod autofill {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_without_user_interface_callback
@@ -838,7 +855,7 @@ pub mod autofill {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map(|value| (client_id, msg.sequence_number, value).into())
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
registration_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
@@ -849,6 +866,21 @@ pub mod autofill {
}
}
match serde_json::from_str::<PasskeyMessage<NativeStatus>>(&message) {
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
native_status_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(error) => {
error!(%error, "Unable to deserialze native status.");
}
}
error!(message, "Received an unknown message2");
}
}
@@ -863,7 +895,7 @@ pub mod autofill {
))
})?;
Ok(IpcServer { server })
Ok(AutofillIpcServer { server })
}
/// Return the path to the IPC server.
@@ -956,8 +988,9 @@ pub mod logging {
use std::{fmt::Write, sync::OnceLock};
use napi::threadsafe_function::{
ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode,
use napi::{
bindgen_prelude::FnArgs,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use tracing::Level;
use tracing_subscriber::{
@@ -968,7 +1001,7 @@ pub mod logging {
Layer,
};
struct JsLogger(OnceLock<ThreadsafeFunction<(LogLevel, String), CalleeHandled>>);
struct JsLogger(OnceLock<ThreadsafeFunction<FnArgs<(LogLevel, String)>>>);
static JS_LOGGER: JsLogger = JsLogger(OnceLock::new());
#[napi]
@@ -1040,13 +1073,13 @@ pub mod logging {
let msg = (event.metadata().level().into(), buffer);
if let Some(logger) = JS_LOGGER.0.get() {
let _ = logger.call(Ok(msg), ThreadsafeFunctionCallMode::NonBlocking);
let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking);
};
}
}
#[napi]
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) {
pub fn init_napi_log(js_log_fn: ThreadsafeFunction<FnArgs<(LogLevel, String)>>) {
let _ = JS_LOGGER.0.set(js_log_fn);
// the log level hierarchy is determined by:
@@ -1117,8 +1150,8 @@ pub mod chromium_importer {
#[napi(object)]
pub struct NativeImporterMetadata {
pub id: String,
pub loaders: Vec<&'static str>,
pub instructions: &'static str,
pub loaders: Vec<String>,
pub instructions: String,
}
impl From<_LoginImportResult> for LoginImportResult {
@@ -1195,7 +1228,7 @@ pub mod chromium_importer {
#[napi]
pub mod autotype {
#[napi]
pub fn get_foreground_window_title() -> napi::Result<String, napi::Status> {
pub fn get_foreground_window_title() -> napi::Result<String> {
autotype::get_foreground_window_title().map_err(|_| {
napi::Error::from_reason(
"Autotype Error: failed to get foreground window title".to_string(),

View File

@@ -14,8 +14,8 @@ tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.build-dependencies]
cc = "=1.2.46"
glob = "=0.3.2"
cc = "=1.2.49"
glob = "=0.3.3"
[lints]
workspace = true

View File

@@ -14,7 +14,9 @@ void runSync(void* context, NSDictionary *params) {
// Map credentials to ASPasswordCredential objects
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
for (NSDictionary *credential in credentials) {
@try {
NSString *type = credential[@"type"];
if ([type isEqualToString:@"password"]) {
@@ -22,33 +24,55 @@ void runSync(void* context, NSDictionary *params) {
NSString *uri = credential[@"uri"];
NSString *username = credential[@"username"];
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc]
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
// Skip credentials with null username since MacOS crashes if we send credentials with empty usernames
if ([username isKindOfClass:[NSNull class]] || username.length == 0) {
NSLog(@"Skipping credential, username is empty: %@", credential);
continue;
}
if (@available(macos 14, *)) {
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
ASPasswordCredentialIdentity *passwordIdentity = [[ASPasswordCredentialIdentity alloc]
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
[mappedCredentials addObject:passwordIdentity];
}
else if (@available(macos 14, *)) {
// Fido2CredentialView uses `userName` (camelCase) while Login uses `username`.
// This is intentional. Fido2 fields are flattened from the FIDO2 spec's nested structure
// (user.name -> userName, rp.id -> rpId) to maintain a clear distinction between these fields.
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"];
// Skip credentials with null username since MacOS crashes if we send credentials with empty usernames
if ([userName isKindOfClass:[NSNull class]] || userName.length == 0) {
NSLog(@"Skipping credential, username is empty: %@", credential);
continue;
}
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity");
id credential = [[passkeyCredentialIdentityClass alloc]
id passkeyIdentity = [[passkeyCredentialIdentityClass alloc]
initWithRelyingPartyIdentifier:rpId
userName:userName
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
[mappedCredentials addObject:passkeyIdentity];
}
}
} @catch (NSException *exception) {
// Silently skip any credential that causes an exception
// to make sure we don't fail the entire sync
// There is likely some invalid data in the credential, and not something the user should/could be asked to correct.
NSLog(@"ERROR: Exception processing credential: %@ - %@", exception.name, exception.reason);
continue;
}
}
[ASCredentialIdentityStore.sharedStore replaceCredentialIdentityEntries:mappedCredentials

View File

@@ -18,9 +18,26 @@ NSString *serializeJson(NSDictionary *dictionary, NSError *error) {
}
NSData *decodeBase64URL(NSString *base64URLString) {
if (base64URLString.length == 0) {
return nil;
}
// Replace URL-safe characters with standard base64 characters
NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"];
base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"];
// Add padding if needed
// Base 64 strings should be a multiple of 4 in length
NSUInteger paddingLength = 4 - (base64String.length % 4);
if (paddingLength < 4) {
NSMutableString *paddedString = [NSMutableString stringWithString:base64String];
for (NSUInteger i = 0; i < paddingLength; i++) {
[paddedString appendString:@"="];
}
base64String = paddedString;
}
// Decode the string
NSData *nsdataFromBase64String = [[NSData alloc]
initWithBase64EncodedString:base64String options:0];

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "1.87.0"
channel = "1.91.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"

View File

@@ -153,7 +153,7 @@ fn add_authenticator() -> std::result::Result<(), String> {
}
}
type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn(
type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "C" fn(
pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions,
ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse,
) -> HRESULT;

View File

@@ -8,63 +8,56 @@
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModule="autofill_extension" customModuleProvider="target">
<connections>
<outlet property="logoImageView" destination="logoImageView" id="logoImageViewOutlet"/>
<outlet property="statusLabel" destination="statusLabel" id="statusLabelOutlet"/>
<outlet property="view" destination="1" id="2"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView hidden="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1">
<rect key="frame" x="0.0" y="0.0" width="378" height="94"/>
<rect key="frame" x="0.0" y="0.0" width="400" height="120"/>
<subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="1uM-r7-H1c">
<rect key="frame" x="184" y="3" width="191" height="32"/>
<buttonCell key="cell" type="push" title="Return Example Password" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="2l4-PO-we5">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent">D</string>
<modifierMask key="keyEquivalentModifierMask" command="YES"/>
</buttonCell>
<connections>
<action selector="passwordSelected:" target="-2" id="yic-EC-GGk"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="NVE-vN-dkz">
<rect key="frame" x="114" y="3" width="76" height="32"/>
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6Up-t3-mwm">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<stackView distribution="fill" orientation="horizontal" alignment="centerY" spacing="20" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="configStackView">
<rect key="frame" x="89" y="35" width="223" height="50"/>
<subviews>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="logoImageView">
<rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/>
<constraint firstAttribute="height" constant="50" id="logoImageHeight"/>
<constraint firstAttribute="width" constant="50" id="logoImageWidth"/>
</constraints>
<connections>
<action selector="cancel:" target="-2" id="Qav-AK-DGt"/>
</connections>
</button>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aNc-0i-CWK">
<rect key="frame" x="112" y="63" width="154" height="16"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="autofill-extension hello" id="0xp-rC-2gr">
<font key="font" metaFont="systemBold"/>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyUpOrDown" image="bitwarden-icon" id="logoImageCell"/>
</imageView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="statusLabel">
<rect key="frame" x="68" y="16" width="157" height="19"/>
<textFieldCell key="cell" sendsActionOnEndEditing="YES" alignment="left" title="Enabling Bitwarden..." id="statusLabelCell">
<font key="font" metaFont="system" size="16"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<visibilityPriorities>
<integer value="1000"/>
<integer value="1000"/>
</visibilityPriorities>
<customSpacing>
<real value="3.4028234663852886e+38"/>
<real value="3.4028234663852886e+38"/>
</customSpacing>
</stackView>
</subviews>
<constraints>
<constraint firstItem="1uM-r7-H1c" firstAttribute="leading" secondItem="NVE-vN-dkz" secondAttribute="trailing" constant="8" id="1UO-J1-LbJ"/>
<constraint firstItem="NVE-vN-dkz" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="1" secondAttribute="leading" constant="20" symbolic="YES" id="3N9-qo-UfS"/>
<constraint firstAttribute="bottom" secondItem="1uM-r7-H1c" secondAttribute="bottom" constant="10" id="4wH-De-nMF"/>
<constraint firstItem="NVE-vN-dkz" firstAttribute="firstBaseline" secondItem="aNc-0i-CWK" secondAttribute="baseline" constant="50" id="Dpq-cK-cPE"/>
<constraint firstAttribute="bottom" secondItem="NVE-vN-dkz" secondAttribute="bottom" constant="10" id="USG-Gg-of3"/>
<constraint firstItem="1uM-r7-H1c" firstAttribute="leading" secondItem="NVE-vN-dkz" secondAttribute="trailing" constant="8" id="a8N-vS-Ew9"/>
<constraint firstAttribute="trailing" secondItem="1uM-r7-H1c" secondAttribute="trailing" constant="10" id="qfT-cw-QQ2"/>
<constraint firstAttribute="centerX" secondItem="aNc-0i-CWK" secondAttribute="centerX" id="uV3-Wn-RA3"/>
<constraint firstItem="aNc-0i-CWK" firstAttribute="top" secondItem="1" secondAttribute="top" constant="15" id="vpR-tf-ebx"/>
<constraint firstItem="configStackView" firstAttribute="centerX" secondItem="1" secondAttribute="centerX" id="stackCenterX"/>
<constraint firstItem="configStackView" firstAttribute="centerY" secondItem="1" secondAttribute="centerY" id="stackCenterY"/>
<constraint firstItem="configStackView" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="1" secondAttribute="leading" constant="20" id="stackLeading"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="configStackView" secondAttribute="trailing" constant="20" id="stackTrailing"/>
</constraints>
<point key="canvasLocation" x="162" y="146"/>
<point key="canvasLocation" x="200" y="60"/>
</customView>
</objects>
<resources>
<image name="bitwarden-icon" width="64" height="64"/>
</resources>
</document>

View File

@@ -11,13 +11,25 @@ import os
class CredentialProviderViewController: ASCredentialProviderViewController {
let logger: Logger
// There is something a bit strange about the initialization/deinitialization in this class.
// Sometimes deinit won't be called after a request has successfully finished,
// which would leave this class hanging in memory and the IPC connection open.
//
// If instead I make this a static, the deinit gets called correctly after each request.
// I think we still might want a static regardless, to be able to reuse the connection if possible.
let client: MacOsProviderClient = {
@IBOutlet weak var statusLabel: NSTextField!
@IBOutlet weak var logoImageView: NSImageView!
// The IPC client to communicate with the Bitwarden desktop app
private var client: MacOsProviderClient?
// Timer for checking connection status
private var connectionMonitorTimer: Timer?
private var lastConnectionStatus: ConnectionStatus = .disconnected
// We changed the getClient method to be async, here's why:
// 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 {
if let client = self.client {
return client
}
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
// Check if the Electron app is running
@@ -29,45 +41,108 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
if !isRunning {
logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch")
// Try to launch the app
// Launch the app and wait for it to be ready
if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") {
let semaphore = DispatchSemaphore(value: 0)
workspace.openApplication(at: appURL,
configuration: NSWorkspace.OpenConfiguration()) { app, error in
await withCheckedContinuation { continuation in
workspace.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { app, error in
if let error = error {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)")
} else if let app = app {
} else {
logger.log("[autofill-extension] Successfully launched Bitwarden Desktop")
} else {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: unknown error")
}
semaphore.signal()
continuation.resume()
}
}
// Wait for launch completion with timeout
_ = semaphore.wait(timeout: .now() + 5.0)
// Add a small delay to allow for initialization
Thread.sleep(forTimeInterval: 1.0)
} else {
logger.error("[autofill-extension] Could not find Bitwarden Desktop app")
}
} else {
logger.log("[autofill-extension] Bitwarden Desktop is running")
}
logger.log("[autofill-extension] Connecting to Bitwarden over IPC")
return MacOsProviderClient.connect()
}()
// Retry connecting to the Bitwarden IPC with an increasing delay
let maxRetries = 20
let delayMs = 500
var newClient: MacOsProviderClient?
for attempt in 1...maxRetries {
logger.log("[autofill-extension] Connection attempt \(attempt)")
// Create a new client instance for each retry
newClient = MacOsProviderClient.connect()
try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds
let connectionStatus = newClient!.getConnectionStatus()
logger.log("[autofill-extension] Connection attempt \(attempt), status: \(connectionStatus == .connected ? "connected" : "disconnected")")
if connectionStatus == .connected {
logger.log("[autofill-extension] Successfully connected to Bitwarden (attempt \(attempt))")
break
} else {
if attempt < maxRetries {
logger.log("[autofill-extension] Retrying connection")
} else {
logger.error("[autofill-extension] Failed to connect after \(maxRetries) attempts, final status: \(connectionStatus == .connected ? "connected" : "disconnected")")
}
}
}
self.client = newClient
return newClient!
}
// Setup the connection monitoring timer
private func setupConnectionMonitoring() {
// Check connection status every 1 second
connectionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.checkConnectionStatus()
}
// Make sure timer runs even when UI is busy
RunLoop.current.add(connectionMonitorTimer!, forMode: .common)
// Initial check
checkConnectionStatus()
}
// Check the connection status by calling into Rust
// If the connection is has changed and is now disconnected, cancel the request
private func checkConnectionStatus() {
// Only check connection status if the client has been initialized.
// Initialization is done asynchronously, so we might be called before it's ready
// In that case we just skip this check and wait for the next timer tick and re-check
guard let client = self.client else {
return
}
// Get the current connection status from Rust
let currentStatus = client.getConnectionStatus()
// Only post notification if state changed
if currentStatus != lastConnectionStatus {
if(currentStatus == .connected) {
logger.log("[autofill-extension] Connection status changed: Connected")
} else {
logger.log("[autofill-extension] Connection status changed: Disconnected")
}
// Save the new status
lastConnectionStatus = currentStatus
// If we just disconnected, try to cancel the request
if currentStatus == .disconnected {
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected"))
}
}
}
init() {
logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
logger.log("[autofill-extension] initializing extension")
super.init(nibName: nil, bundle: nil)
super.init(nibName: "CredentialProviderViewController", bundle: nil)
// Setup connection monitoring now that self is available
setupConnectionMonitoring()
}
required init?(coder: NSCoder) {
@@ -76,45 +151,109 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
deinit {
logger.log("[autofill-extension] deinitializing extension")
// Stop the connection monitor timer
connectionMonitorTimer?.invalidate()
connectionMonitorTimer = nil
}
private func getWindowPosition() async -> Position {
let screenHeight = NSScreen.main?.frame.height ?? 1440
@IBAction func cancel(_ sender: AnyObject?) {
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue))
logger.log("[autofill-extension] position: Getting window position")
// To whomever is reading this. Sorry. But MacOS couldn't give us an accurate window positioning, possibly due to animations
// So I added some retry logic, as well as a fall back to the mouse position which is likely at the sort of the right place.
// In my testing we often succed after 4-7 attempts.
// Wait for window frame to stabilize (animation to complete)
var lastFrame: CGRect = .zero
var stableCount = 0
let requiredStableChecks = 3
let maxAttempts = 20
var attempts = 0
while stableCount < requiredStableChecks && attempts < maxAttempts {
let currentFrame: CGRect = self.view.window?.frame ?? .zero
if currentFrame.equalTo(lastFrame) && !currentFrame.equalTo(.zero) {
stableCount += 1
} else {
stableCount = 0
lastFrame = currentFrame
}
@IBAction func passwordSelected(_ sender: AnyObject?) {
let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps (16.67ms)
attempts += 1
}
private func getWindowPosition() -> Position {
let frame = self.view.window?.frame ?? .zero
let screenHeight = NSScreen.main?.frame.height ?? 0
// frame.width and frame.height is always 0. Estimating works OK for now.
let estimatedWidth:CGFloat = 400;
let estimatedHeight:CGFloat = 200;
let centerX = Int32(round(frame.origin.x + estimatedWidth/2))
let centerY = Int32(round(screenHeight - (frame.origin.y + estimatedHeight/2)))
let finalWindowFrame = self.view.window?.frame ?? .zero
logger.log("[autofill-extension] position: Final window frame: \(NSStringFromRect(finalWindowFrame))")
// Use stabilized window frame if available, otherwise fallback to mouse position
if finalWindowFrame.origin.x != 0 || finalWindowFrame.origin.y != 0 {
let centerX = Int32(round(finalWindowFrame.origin.x))
let centerY = Int32(round(screenHeight - finalWindowFrame.origin.y))
logger.log("[autofill-extension] position: Using window position: x=\(centerX), y=\(centerY)")
return Position(x: centerX, y: centerY)
} else {
// Fallback to mouse position
let mouseLocation = NSEvent.mouseLocation
let mouseX = Int32(round(mouseLocation.x))
let mouseY = Int32(round(screenHeight - mouseLocation.y))
logger.log("[autofill-extension] position: Using mouse position fallback: x=\(mouseX), y=\(mouseY)")
return Position(x: mouseX, y: mouseY)
}
}
override func loadView() {
let view = NSView()
// Hide the native window since we only need the IPC connection
view.isHidden = true
self.view = view
override func viewDidLoad() {
super.viewDidLoad()
// Initially hide the view
self.view.isHidden = true
}
override func prepareInterfaceForExtensionConfiguration() {
// Show the configuration UI
self.view.isHidden = false
// Set the localized message
statusLabel.stringValue = NSLocalizedString("autofillConfigurationMessage", comment: "Message shown when Bitwarden is enabled in system settings")
// Send the native status request asynchronously
Task {
let client = await getClient()
client.sendNativeStatus(key: "request-sync", value: "")
}
// Complete the configuration after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.extensionContext.completeExtensionConfigurationRequest()
}
}
/*
In order to implement this method, we need to query the state of the vault to be unlocked and have one and only one matching credential so that it doesn't need to show ui.
If we do show UI, it's going to fail and disconnect after the platform timeout which is 3s.
For now we just claim to always need UI displayed.
*/
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
let timeoutTimer = createTimer()
let error = ASExtensionError(.userInteractionRequired)
self.extensionContext.cancelRequest(withError: error)
return
}
/*
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
UI and call this method. Show appropriate UI for authenticating the user then provide the password
by completing the extension request with the associated ASPasswordCredential.
*/
override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) {
let timeoutTimer = createTimer()
if let request = credentialRequest as? ASPasskeyCredentialRequest {
if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity {
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)")
logger.log("[autofill-extension] prepareInterfaceToProvideCredential (passkey) called \(request)")
class CallbackImpl: PreparePasskeyAssertionCallback {
let ctx: ASCredentialProviderExtensionContext
@@ -154,6 +293,11 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged
}
/*
We're still using the old request type here, because we're sending the same data, we're expecting a single credential to be used
*/
Task {
let windowPosition = await self.getWindowPosition()
let req = PasskeyAssertionWithoutUserInterfaceRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
credentialId: passkeyIdentity.credentialID,
@@ -162,10 +306,12 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
recordIdentifier: passkeyIdentity.recordIdentifier,
clientDataHash: request.clientDataHash,
userVerification: userVerification,
windowXy: self.getWindowPosition()
windowXy: windowPosition
)
self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
let client = await getClient()
client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
}
return
}
}
@@ -176,16 +322,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request"))
}
/*
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
UI and call this method. Show appropriate UI for authenticating the user then provide the password
by completing the extension request with the associated ASPasswordCredential.
override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) {
}
*/
private func createTimer() -> DispatchWorkItem {
// Create a timer for 600 second timeout
let timeoutTimer = DispatchWorkItem { [weak self] in
@@ -246,6 +382,18 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged
}
// Convert excluded credentials to an array of credential IDs
var excludedCredentialIds: [Data] = []
if #available(macOSApplicationExtension 15.0, *) {
if let excludedCreds = request.excludedCredentials {
excludedCredentialIds = excludedCreds.map { $0.credentialID }
}
}
logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration")
Task {
let windowPosition = await self.getWindowPosition()
let req = PasskeyRegistrationRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
userName: passkeyIdentity.userName,
@@ -253,11 +401,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
clientDataHash: request.clientDataHash,
userVerification: userVerification,
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) },
windowXy: self.getWindowPosition()
windowXy: windowPosition,
excludedCredentials: excludedCredentialIds
)
logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration")
self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
let client = await getClient()
client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
}
return
}
}
@@ -310,18 +460,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged
}
let timeoutTimer = createTimer()
Task {
let windowPosition = await self.getWindowPosition()
let req = PasskeyAssertionRequest(
rpId: requestParameters.relyingPartyIdentifier,
clientDataHash: requestParameters.clientDataHash,
userVerification: userVerification,
allowedCredentials: requestParameters.allowedCredentials,
windowXy: self.getWindowPosition()
//extensionInput: requestParameters.extensionInput,
windowXy: windowPosition
//extensionInput: requestParameters.extensionInput, // We don't support extensions yet
)
let timeoutTimer = createTimer()
self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
let client = await getClient()
client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
}
return
}
}

View File

@@ -10,9 +10,9 @@
<dict>
<key>ProvidesPasskeys</key>
<true/>
<key>ShowsConfigurationUI</key>
<true/>
</dict>
<key>ASCredentialProviderExtensionShowsConfigurationUI</key>
<false/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.authentication-services-credential-provider-ui</string>

View File

@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,2 @@
/* Message shown during passkey configuration */
"autofillConfigurationMessage" = "Enabling Bitwarden...";

View File

@@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; };
3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; };
9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */; };
9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9AE2990D2DFB57A200AAE454 /* Localizable.strings */; };
E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; };
E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; };
E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; };
@@ -18,6 +20,8 @@
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_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>"; };
9AE2990C2DFB57A200AAE454 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = "<group>"; };
D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseDeveloper.xcconfig; sourceTree = "<group>"; };
E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -41,6 +45,14 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
9AE2990E2DFB57A200AAE454 /* en.lproj */ = {
isa = PBXGroup;
children = (
9AE2990D2DFB57A200AAE454 /* Localizable.strings */,
);
path = en.lproj;
sourceTree = "<group>";
};
E1DF711D2B342E2800F29026 = {
isa = PBXGroup;
children = (
@@ -73,6 +85,8 @@
E1DF71402B342F6900F29026 /* autofill-extension */ = {
isa = PBXGroup;
children = (
9AE2990E2DFB57A200AAE454 /* en.lproj */,
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */,
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */,
E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */,
E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */,
@@ -124,6 +138,7 @@
knownRegions = (
en,
Base,
sv,
);
mainGroup = E1DF711D2B342E2800F29026;
productRefGroup = E1DF71272B342E2800F29026 /* Products */;
@@ -141,6 +156,8 @@
buildActionMask = 2147483647;
files = (
E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */,
9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */,
9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -159,6 +176,14 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
9AE2990D2DFB57A200AAE454 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
9AE2990C2DFB57A200AAE454 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */ = {
isa = PBXVariantGroup;
children = (

View File

@@ -19,7 +19,7 @@
"yargs": "18.0.0"
},
"devDependencies": {
"@types/node": "22.19.1",
"@types/node": "22.19.2",
"typescript": "5.4.2"
}
},
@@ -117,9 +117,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"license": "MIT",
"peer": true,
"dependencies": {

View File

@@ -24,7 +24,7 @@
"yargs": "18.0.0"
},
"devDependencies": {
"@types/node": "22.19.1",
"@types/node": "22.19.2",
"typescript": "5.4.2"
},
"_moduleAliases": {

View File

@@ -18,6 +18,7 @@
"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": "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\"",
@@ -44,10 +45,9 @@
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
"pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never",
"pack:mac:mas:with-extension": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never",
"pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
"pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
@@ -55,11 +55,8 @@
"dist:lin": "npm run build && npm run pack:lin",
"dist:lin:arm64": "npm run build && npm run pack:lin:arm64",
"dist:mac": "npm run build && npm run pack:mac",
"dist:mac:with-extension": "npm run build && npm run pack:mac:with-extension",
"dist:mac:mas": "npm run build && npm run pack:mac:mas",
"dist:mac:mas:with-extension": "npm run build && npm run pack:mac:mas:with-extension",
"dist:mac:masdev": "npm run build:dev && npm run pack:mac:masdev",
"dist:mac:masdev:with-extension": "npm run build:dev && npm run pack:mac:masdev:with-extension",
"dist:mac:masdev": "npm run build && npm run pack:mac:masdev",
"dist:win": "npm run build && npm run pack:win",
"dist:win:ci": "npm run build && npm run pack:win:ci",
"publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always",

View File

@@ -6,8 +6,6 @@
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>

View File

@@ -4,9 +4,9 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View File

@@ -6,19 +6,19 @@
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.security.network.client</key>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.device.usb</key>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
<array>
@@ -36,7 +36,5 @@
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

View File

@@ -16,7 +16,7 @@ async function run(context) {
const appPath = `${context.appOutDir}/${appName}.app`;
const macBuild = context.electronPlatformName === "darwin";
const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName);
const copyAutofillExtension = ["darwin", "mas"].includes(context.electronPlatformName);
const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds
let shouldResign = false;

View File

@@ -37,6 +37,6 @@ concurrently(
{
prefix: "name",
outputStream: process.stdout,
killOthers: ["success", "failure"],
killOthersOn: ["success", "failure"],
},
);

View File

@@ -34,6 +34,6 @@ concurrently(
{
prefix: "name",
outputStream: process.stdout,
killOthers: ["success", "failure"],
killOthersOn: ["success", "failure"],
},
);

View File

@@ -45,11 +45,14 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard";
import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component";
import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component";
import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
import { SendComponent } from "./tools/send/send.component";
import { SendV2Component } from "./tools/send-v2/send-v2.component";
@@ -120,12 +123,16 @@ const routes: Routes = [
canActivate: [authGuard],
},
{
path: "passkeys",
component: Fido2PlaceholderComponent,
path: "fido2-assertion",
component: Fido2VaultComponent,
},
{
path: "passkeys",
component: Fido2PlaceholderComponent,
path: "fido2-creation",
component: Fido2CreateComponent,
},
{
path: "fido2-excluded",
component: Fido2ExcludedCiphersComponent,
},
{
path: "",
@@ -271,7 +278,7 @@ const routes: Routes = [
},
{
path: "lock",
canActivate: [lockGuard()],
canActivate: [lockGuard(), reactiveUnlockVaultGuard],
data: {
pageIcon: LockIcon,
pageTitle: {

View File

@@ -104,7 +104,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
<ng-template #exportVault></ng-template>
<ng-template #appGenerator></ng-template>
<ng-template #loginApproval></ng-template>
<app-header></app-header>
<app-header *ngIf="showHeader$ | async"></app-header>
<div id="container">
<div class="loading" *ngIf="loading">
@@ -141,6 +141,7 @@ export class AppComponent implements OnInit, OnDestroy {
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
loginApprovalModalRef: ViewContainerRef;
showHeader$ = this.accountService.showHeader$;
loading = false;
private lastActivity: Date = null;

View File

@@ -1,122 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { BehaviorSubject, Observable } from "rxjs";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../autofill/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
standalone: true,
imports: [CommonModule],
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<div *ngFor="let item of cipherIds$ | async">
<button
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="chooseCipher(item)"
>
{{ item }}
</button>
</div>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="confirmPasskey()"
>
Confirm passkey
</button>
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="closeModal()"
>
Close
</button>
</div>
`,
})
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
cipherIds$: Observable<string[]>;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly router: Router,
) {}
ngOnInit() {
this.session = this.fido2UserInterfaceService.getCurrentSession();
this.cipherIds$ = this.session?.availableCipherIds$;
}
async chooseCipher(cipherId: string) {
// For now: Set UV to true
this.session?.confirmChosenCipher(cipherId, true);
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
}
ngOnDestroy() {
this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
}
async confirmPasskey() {
try {
// Retrieve the current UI session to control the flow
if (!this.session) {
// todo: handle error
throw new Error("No session found");
}
// If we want to we could submit information to the session in order to create the credential
// const cipher = await session.createCredential({
// userHandle: "userHandle2",
// userName: "username2",
// credentialName: "zxsd2",
// rpId: "webauthn.io",
// userVerification: true,
// });
this.session.notifyConfirmNewCredential(true);
// Not sure this clean up should happen here or in session.
// The session currently toggles modal on and send us here
// But if this route is somehow opened outside of session we want to make sure we clean up?
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
} catch {
// TODO: Handle error appropriately
}
}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
this.session.notifyConfirmNewCredential(false);
// little bit hacky:
this.session.confirmChosenCipher(null);
}
}

View File

@@ -1,9 +1,9 @@
<bit-layout>
<bit-layout class="!tw-h-full">
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault"></bit-nav-item>
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="new-sends"></bit-nav-item>
<app-send-filters-nav></app-send-filters-nav>
</app-side-nav>
<router-outlet></router-outlet>

View File

@@ -1,3 +1,4 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterModule } from "@angular/router";
import { mock } from "jest-mock-extended";
@@ -5,8 +6,18 @@ import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { NavigationModule } from "@bitwarden/components";
import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
import { DesktopLayoutComponent } from "./desktop-layout.component";
// Mock the child component to isolate DesktopLayoutComponent testing
@Component({
selector: "app-send-filters-nav",
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockSendFiltersNavComponent {}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
@@ -34,7 +45,12 @@ describe("DesktopLayoutComponent", () => {
useValue: mock<I18nService>(),
},
],
}).compileComponents();
})
.overrideComponent(DesktopLayoutComponent, {
remove: { imports: [SendFiltersNavComponent] },
add: { imports: [MockSendFiltersNavComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(DesktopLayoutComponent);
component = fixture.componentInstance;
@@ -58,4 +74,11 @@ describe("DesktopLayoutComponent", () => {
expect(ngContent).toBeTruthy();
});
it("renders send filters navigation component", () => {
const compiled = fixture.nativeElement;
const sendFiltersNav = compiled.querySelector("app-send-filters-nav");
expect(sendFiltersNav).toBeTruthy();
});
});

View File

@@ -5,13 +5,22 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-layout",
imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent],
imports: [
RouterModule,
I18nPipe,
LayoutComponent,
NavigationModule,
DesktopSideNavComponent,
SendFiltersNavComponent,
],
templateUrl: "./desktop-layout.component.html",
})
export class DesktopLayoutComponent {

View File

@@ -345,6 +345,7 @@ const safeProviders: SafeProvider[] = [
ConfigService,
Fido2AuthenticatorServiceAbstraction,
AccountService,
AuthService,
PlatformUtilsService,
],
}),

View File

@@ -1,13 +1,21 @@
<bit-dialog #dialog dialogSize="large" background="alt">
<span bitDialogTitle>{{ "importData" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-relative">
<tools-import
(formLoading)="this.loading = $event"
(formDisabled)="this.disabled = $event"
(onSuccessfulImport)="this.onSuccessfulImport($event)"
[onImportFromBrowser]="this.onImportFromBrowser"
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
[class.tw-invisible]="loading"
></tools-import>
@if (loading) {
<div class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x tw-text-primary-600" aria-hidden="true"></i>
</div>
}
</div>
</ng-container>
<ng-container bitDialogFooter>
<button

View File

@@ -0,0 +1,25 @@
<bit-nav-group
icon="bwi-send"
[text]="'send' | i18n"
route="new-sends"
(click)="selectTypeAndNavigate()"
>
<bit-nav-item
icon="bwi-send"
[text]="'allSends' | i18n"
(click)="selectTypeAndNavigate(null); $event.stopPropagation()"
[forceActiveStyles]="activeSendType() === null"
></bit-nav-item>
<bit-nav-item
icon="bwi-file-text"
[text]="'sendTypeText' | i18n"
(click)="selectTypeAndNavigate(SendType.Text); $event.stopPropagation()"
[forceActiveStyles]="activeSendType() === SendType.Text"
></bit-nav-item>
<bit-nav-item
icon="bwi-file"
[text]="'sendTypeFile' | i18n"
(click)="selectTypeAndNavigate(SendType.File); $event.stopPropagation()"
[forceActiveStyles]="activeSendType() === SendType.File"
></bit-nav-item>
</bit-nav-group>

View File

@@ -0,0 +1,204 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { RouterTestingHarness } from "@angular/router/testing";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { NavigationModule } from "@bitwarden/components";
import { SendListFiltersService } from "@bitwarden/send-ui";
import { SendFiltersNavComponent } from "./send-filters-nav.component";
@Component({ template: "", changeDetection: ChangeDetectionStrategy.OnPush })
class DummyComponent {}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
describe("SendFiltersNavComponent", () => {
let component: SendFiltersNavComponent;
let fixture: ComponentFixture<SendFiltersNavComponent>;
let harness: RouterTestingHarness;
let filterFormValueSubject: BehaviorSubject<{ sendType: SendType | null }>;
let mockSendListFiltersService: Partial<SendListFiltersService>;
beforeEach(async () => {
filterFormValueSubject = new BehaviorSubject<{ sendType: SendType | null }>({
sendType: null,
});
mockSendListFiltersService = {
filterForm: {
value: { sendType: null },
valueChanges: filterFormValueSubject.asObservable(),
patchValue: jest.fn((value) => {
mockSendListFiltersService.filterForm.value = {
...mockSendListFiltersService.filterForm.value,
...value,
};
filterFormValueSubject.next(mockSendListFiltersService.filterForm.value);
}),
} as any,
filters$: filterFormValueSubject.asObservable(),
};
await TestBed.configureTestingModule({
imports: [SendFiltersNavComponent, NavigationModule],
providers: [
provideRouter([
{ path: "vault", component: DummyComponent },
{ path: "new-sends", component: DummyComponent },
]),
{
provide: SendListFiltersService,
useValue: mockSendListFiltersService,
},
{
provide: I18nService,
useValue: {
t: jest.fn((key) => key),
},
},
],
}).compileComponents();
// Create harness and navigate to initial route
harness = await RouterTestingHarness.create("/vault");
// Create the component fixture separately (not a routed component)
fixture = TestBed.createComponent(SendFiltersNavComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("creates component", () => {
expect(component).toBeTruthy();
});
it("renders bit-nav-group with Send icon and text", () => {
const compiled = fixture.nativeElement;
const navGroup = compiled.querySelector("bit-nav-group");
expect(navGroup).toBeTruthy();
expect(navGroup.getAttribute("icon")).toBe("bwi-send");
});
it("component exposes SendType enum for template", () => {
expect(component["SendType"]).toBe(SendType);
});
describe("isSendRouteActive", () => {
it("returns true when on /new-sends route", async () => {
await harness.navigateByUrl("/new-sends");
fixture.detectChanges();
expect(component["isSendRouteActive"]()).toBe(true);
});
it("returns false when not on /new-sends route", () => {
expect(component["isSendRouteActive"]()).toBe(false);
});
});
describe("activeSendType", () => {
it("returns the active send type when on send route and filter type is set", async () => {
await harness.navigateByUrl("/new-sends");
mockSendListFiltersService.filterForm.value = { sendType: SendType.Text };
filterFormValueSubject.next({ sendType: SendType.Text });
fixture.detectChanges();
expect(component["activeSendType"]()).toBe(SendType.Text);
});
it("returns undefined when not on send route", () => {
mockSendListFiltersService.filterForm.value = { sendType: SendType.Text };
filterFormValueSubject.next({ sendType: SendType.Text });
fixture.detectChanges();
expect(component["activeSendType"]()).toBeUndefined();
});
it("returns null when on send route but no type is selected", async () => {
await harness.navigateByUrl("/new-sends");
mockSendListFiltersService.filterForm.value = { sendType: null };
filterFormValueSubject.next({ sendType: null });
fixture.detectChanges();
expect(component["activeSendType"]()).toBeNull();
});
});
describe("selectTypeAndNavigate", () => {
it("clears the sendType filter when called with no parameter", async () => {
await component["selectTypeAndNavigate"]();
expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
sendType: null,
});
});
it("updates filter form with Text type", async () => {
await component["selectTypeAndNavigate"](SendType.Text);
expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
sendType: SendType.Text,
});
});
it("updates filter form with File type", async () => {
await component["selectTypeAndNavigate"](SendType.File);
expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
sendType: SendType.File,
});
});
it("navigates to /new-sends when not on send route", async () => {
expect(harness.routeNativeElement?.textContent).toBeDefined();
await component["selectTypeAndNavigate"](SendType.Text);
const currentUrl = TestBed.inject(Router).url;
expect(currentUrl).toBe("/new-sends");
expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
sendType: SendType.Text,
});
});
it("does not navigate when already on send route (component is reactive)", async () => {
await harness.navigateByUrl("/new-sends");
const router = TestBed.inject(Router);
const navigateSpy = jest.spyOn(router, "navigate");
await component["selectTypeAndNavigate"](SendType.Text);
expect(navigateSpy).not.toHaveBeenCalled();
expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
sendType: SendType.Text,
});
});
it("navigates when clearing filter from different route", async () => {
await component["selectTypeAndNavigate"](); // No parameter = clear filter
const currentUrl = TestBed.inject(Router).url;
expect(currentUrl).toBe("/new-sends");
expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
sendType: null,
});
});
});
});

View File

@@ -0,0 +1,54 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter, map, startWith } from "rxjs";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { NavigationModule } from "@bitwarden/components";
import { SendListFiltersService } from "@bitwarden/send-ui";
import { I18nPipe } from "@bitwarden/ui-common";
/**
* Navigation component that renders Send filter options in the sidebar.
* Fully reactive using signals - no manual subscriptions or method-based computed values.
* - Parent "Send" nav-group clears filter (shows all sends)
* - Child "Text"/"File" items set filter to specific type
* - Active states computed reactively from filter signal + route signal
*/
@Component({
selector: "app-send-filters-nav",
templateUrl: "./send-filters-nav.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NavigationModule, I18nPipe],
})
export class SendFiltersNavComponent {
protected readonly SendType = SendType;
private readonly filtersService = inject(SendListFiltersService);
private readonly router = inject(Router);
private readonly currentFilter = toSignal(this.filtersService.filters$);
// Track whether current route is the send route
private readonly isSendRouteActive = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map((event) => (event as NavigationEnd).urlAfterRedirects.includes("/new-sends")),
startWith(this.router.url.includes("/new-sends")),
),
{ initialValue: this.router.url.includes("/new-sends") },
);
// Computed: Active send type (null when on send route with no filter, undefined when not on send route)
protected readonly activeSendType = computed(() => {
return this.isSendRouteActive() ? this.currentFilter()?.sendType : undefined;
});
// Update send filter and navigate to /new-sends (only if not already there - send-v2 component reacts to filter changes)
protected async selectTypeAndNavigate(type?: SendType): Promise<void> {
this.filtersService.filterForm.patchValue({ sendType: type !== undefined ? type : null });
if (!this.router.url.includes("/new-sends")) {
await this.router.navigate(["/new-sends"]);
}
}
}

View File

@@ -1,4 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
@@ -15,6 +19,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SendListFiltersService } from "@bitwarden/send-ui";
import * as utils from "../../../utils";
import { SearchBarService } from "../../layout/search/search-bar.service";
@@ -35,6 +40,8 @@ describe("SendV2Component", () => {
let broadcasterService: MockProxy<BroadcasterService>;
let accountService: MockProxy<AccountService>;
let policyService: MockProxy<PolicyService>;
let sendListFiltersService: SendListFiltersService;
let changeDetectorRef: MockProxy<ChangeDetectorRef>;
beforeEach(async () => {
sendService = mock<SendService>();
@@ -42,6 +49,13 @@ describe("SendV2Component", () => {
broadcasterService = mock<BroadcasterService>();
accountService = mock<AccountService>();
policyService = mock<PolicyService>();
changeDetectorRef = mock<ChangeDetectorRef>();
// Create real SendListFiltersService with mocked dependencies
const formBuilder = new FormBuilder();
const i18nService = mock<I18nService>();
i18nService.t.mockImplementation((key: string) => key);
sendListFiltersService = new SendListFiltersService(i18nService, formBuilder);
// Mock sendViews$ observable
sendService.sendViews$ = of([]);
@@ -51,6 +65,10 @@ describe("SendV2Component", () => {
accountService.activeAccount$ = of({ id: "test-user-id" } as any);
policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false));
// Mock SearchService methods needed by base component
const mockSearchService = mock<SearchService>();
mockSearchService.isSearchable.mockResolvedValue(false);
await TestBed.configureTestingModule({
imports: [SendV2Component],
providers: [
@@ -59,7 +77,7 @@ describe("SendV2Component", () => {
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: BroadcasterService, useValue: broadcasterService },
{ provide: SearchService, useValue: mock<SearchService>() },
{ provide: SearchService, useValue: mockSearchService },
{ provide: PolicyService, useValue: policyService },
{ provide: SearchBarService, useValue: searchBarService },
{ provide: LogService, useValue: mock<LogService>() },
@@ -67,6 +85,8 @@ describe("SendV2Component", () => {
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: AccountService, useValue: accountService },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
{ provide: ChangeDetectorRef, useValue: changeDetectorRef },
],
}).compileComponents();
@@ -331,7 +351,6 @@ describe("SendV2Component", () => {
describe("load", () => {
it("sets loading states correctly", async () => {
jest.spyOn(component, "search").mockResolvedValue();
jest.spyOn(component, "selectAll");
expect(component.loaded).toBeFalsy();
@@ -341,14 +360,17 @@ describe("SendV2Component", () => {
expect(component.loaded).toBe(true);
});
it("calls selectAll when onSuccessfulLoad is not set", async () => {
it("sets up sendViews$ subscription", async () => {
const mockSends = [new SendView(), new SendView()];
sendService.sendViews$ = of(mockSends);
jest.spyOn(component, "search").mockResolvedValue();
jest.spyOn(component, "selectAll");
component.onSuccessfulLoad = null;
await component.load();
expect(component.selectAll).toHaveBeenCalled();
// Give observable time to emit
await new Promise((resolve) => setTimeout(resolve, 10));
expect(component.sends).toEqual(mockSends);
});
it("calls onSuccessfulLoad when it is set", async () => {

View File

@@ -2,8 +2,9 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { mergeMap } from "rxjs";
import { mergeMap, Subscription } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
@@ -14,11 +15,13 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SendListFiltersService } from "@bitwarden/send-ui";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { SearchBarService } from "../../layout/search/search-bar.service";
@@ -55,6 +58,9 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
// Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
action: Action = Action.None;
// Subscription for sendViews$ cleanup
private sendViewsSubscription: Subscription;
constructor(
sendService: SendService,
i18nService: I18nService,
@@ -71,6 +77,7 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
toastService: ToastService,
accountService: AccountService,
private cdr: ChangeDetectorRef,
private sendListFiltersService: SendListFiltersService,
) {
super(
sendService,
@@ -88,11 +95,16 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
);
// Listen to search bar changes and update the Send list filter
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.searchText$.subscribe((searchText) => {
this.searchBarService.searchText$.pipe(takeUntilDestroyed()).subscribe((searchText) => {
this.searchText = searchText;
this.searchTextChanged();
setTimeout(() => this.cdr.detectChanges(), 250);
});
// Listen to filter changes from sidebar navigation
this.sendListFiltersService.filterForm.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((filters) => {
this.applySendTypeFilter(filters);
});
}
@@ -103,6 +115,10 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
await super.ngOnInit();
// Read current filter synchronously to avoid race condition on navigation
const currentFilter = this.sendListFiltersService.filterForm.value;
this.applySendTypeFilter(currentFilter);
// Listen for sync completion events to refresh the Send list
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
@@ -118,8 +134,18 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
await this.load();
}
// Apply send type filter to display: centralized logic for initial load and filter changes
private applySendTypeFilter(filters: Partial<{ sendType: SendType | null }>): void {
if (filters.sendType === null || filters.sendType === undefined) {
this.selectAll();
} else {
this.selectType(filters.sendType);
}
}
// Clean up subscriptions and disable search bar when component is destroyed
ngOnDestroy() {
this.sendViewsSubscription?.unsubscribe();
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.searchBarService.setEnabled(false);
}
@@ -130,7 +156,12 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
// Note: The filter parameter is ignored in this implementation for desktop-specific behavior.
async load(filter: (send: SendView) => boolean = null) {
this.loading = true;
this.sendService.sendViews$
// Recreate subscription on each load (required for sync refresh)
// Manual cleanup in ngOnDestroy is intentional - load() is called multiple times
this.sendViewsSubscription?.unsubscribe();
this.sendViewsSubscription = this.sendService.sendViews$
.pipe(
mergeMap(async (sends) => {
this.sends = sends;
@@ -143,9 +174,6 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
.subscribe();
if (this.onSuccessfulLoad != null) {
await this.onSuccessfulLoad();
} else {
// Default action
this.selectAll();
}
this.loading = false;
this.loaded = true;

View File

@@ -46,7 +46,9 @@
</div>
<div class="box-content-row" appBoxRow *ngIf="editMode && type === sendType.File">
<label for="file">{{ "file" | i18n }}</label>
<div class="row-main">{{ send.file.fileName }} ({{ send.file.sizeName }})</div>
<div class="row-main tw-text-wrap tw-break-all">
{{ send.file.fileName }} ({{ send.file.sizeName }})
</div>
</div>
<div class="box-content-row" appBoxRow *ngIf="type === sendType.Text">
<label for="text">{{ "text" | i18n }}</label>

View File

@@ -0,0 +1,42 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { combineLatest, map, switchMap, distinctUntilChanged } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
/**
* Reactive route guard that redirects to the unlocked vault.
* Redirects to vault when unlocked in main window.
*/
export const reactiveUnlockVaultGuard: CanActivateFn = () => {
const router = inject(Router);
const authService = inject(AuthService);
const accountService = inject(AccountService);
const desktopSettingsService = inject(DesktopSettingsService);
return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe(
switchMap(([account, modalMode]) => {
if (!account) {
return [true];
}
// Monitor when the vault has been unlocked.
return authService.authStatusFor$(account.id).pipe(
distinctUntilChanged(),
map((authStatus) => {
// If vault is unlocked and we're not in modal mode, redirect to vault
if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) {
return router.createUrlTree(["/vault"]);
}
// Otherwise keep user on the lock screen
return true;
}),
);
}),
);
};

View File

@@ -37,7 +37,7 @@ export class MainSshAgentService {
init() {
// handle sign request passing to UI
sshagent
.serve(async (err: Error, sshUiRequest: sshagent.SshUiRequest) => {
.serve(async (err: Error | null, sshUiRequest: sshagent.SshUiRequest): Promise<boolean> => {
// clear all old (> SIGN_TIMEOUT) requests
this.requestResponses = this.requestResponses.filter(
(response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT),

View File

@@ -0,0 +1,66 @@
<div class="tw-flex tw-flex-col tw-h-full tw-bg-background-alt">
<bit-section
disableMargin
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
(click)="closeModal()"
[label]="'close' | i18n"
>
{{ "close" | i18n }}
</button>
</bit-section-header>
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col">
<div *ngIf="(ciphers$ | async)?.length === 0; else hasCiphers">
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
<div class="tw-flex tw-flex-col tw-gap-2">
{{ "noMatchingLoginsForSite" | i18n }}
</div>
<button bitButton type="button" buttonType="primary" (click)="confirmPasskey()">
{{ "savePasskeyNewLogin" | i18n }}
</button>
</div>
</div>
<ng-template #hasCiphers>
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="addCredentialToCipher(c)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
<button bitLink [title]="c.name" type="button">
{{ c.name }}
</button>
<span slot="secondary">{{ c.subTitle }}</span>
<span bitBadge slot="end">{{ "save" | i18n }}</span>
</button>
</bit-item>
<bit-item class="">
<button
bitLink
linkType="primary"
type="button"
bit-item-content
(click)="confirmPasskey()"
>
<a bitLink linkType="primary" class="tw-font-medium tw-text-base">
{{ "saveNewPasskey" | i18n }}
</a>
</button>
</bit-item>
</ng-template>
</bit-section>
</div>

View File

@@ -0,0 +1,238 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
import { Fido2CreateComponent } from "./fido2-create.component";
describe("Fido2CreateComponent", () => {
let component: Fido2CreateComponent;
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
let mockAccountService: MockProxy<AccountService>;
let mockCipherService: MockProxy<CipherService>;
let mockDesktopAutofillService: MockProxy<DesktopAutofillService>;
let mockDialogService: MockProxy<DialogService>;
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
let mockLogService: MockProxy<LogService>;
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
let mockRouter: MockProxy<Router>;
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
let mockI18nService: MockProxy<I18nService>;
const activeAccountSubject = new BehaviorSubject<Account | null>({
id: "test-user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
});
beforeEach(async () => {
mockDesktopSettingsService = mock<DesktopSettingsService>();
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
mockAccountService = mock<AccountService>();
mockCipherService = mock<CipherService>();
mockDesktopAutofillService = mock<DesktopAutofillService>();
mockDialogService = mock<DialogService>();
mockDomainSettingsService = mock<DomainSettingsService>();
mockLogService = mock<LogService>();
mockPasswordRepromptService = mock<PasswordRepromptService>();
mockRouter = mock<Router>();
mockSession = mock<DesktopFido2UserInterfaceSession>();
mockI18nService = mock<I18nService>();
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
mockAccountService.activeAccount$ = activeAccountSubject;
await TestBed.configureTestingModule({
providers: [
Fido2CreateComponent,
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: CipherService, useValue: mockCipherService },
{ provide: DesktopAutofillService, useValue: mockDesktopAutofillService },
{ provide: DialogService, useValue: mockDialogService },
{ provide: DomainSettingsService, useValue: mockDomainSettingsService },
{ provide: LogService, useValue: mockLogService },
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
component = TestBed.inject(Fido2CreateComponent);
});
afterEach(() => {
jest.restoreAllMocks();
});
function createMockCiphers(): CipherView[] {
const cipher1 = new CipherView();
cipher1.id = "cipher-1";
cipher1.name = "Test Cipher 1";
cipher1.type = CipherType.Login;
cipher1.login = {
username: "test1@example.com",
uris: [{ uri: "https://example.com", match: null }],
matchesUri: jest.fn().mockReturnValue(true),
get hasFido2Credentials() {
return false;
},
} as any;
cipher1.reprompt = CipherRepromptType.None;
cipher1.deletedDate = null;
return [cipher1];
}
describe("ngOnInit", () => {
beforeEach(() => {
mockSession.getRpId.mockResolvedValue("example.com");
Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", {
get: jest.fn().mockReturnValue({
userHandle: new Uint8Array([1, 2, 3]),
}),
configurable: true,
});
mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set<string>()));
});
it("should initialize session and set show header to false", async () => {
const mockCiphers = createMockCiphers();
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
await component.ngOnInit();
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
expect(component.session).toBe(mockSession);
});
it("should show error dialog when no active session found", async () => {
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await component.ngOnInit();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "unableToSavePasskey" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
acceptAction: expect.any(Function),
cancelButtonText: null,
});
});
});
describe("addCredentialToCipher", () => {
beforeEach(() => {
component.session = mockSession;
});
it("should add passkey to cipher", async () => {
const cipher = createMockCiphers()[0];
await component.addCredentialToCipher(cipher);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
});
it("should not add passkey when password reprompt is cancelled", async () => {
const cipher = createMockCiphers()[0];
cipher.reprompt = CipherRepromptType.Password;
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
await component.addCredentialToCipher(cipher);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
});
it("should call openSimpleDialog when cipher already has a fido2 credential", async () => {
const cipher = createMockCiphers()[0];
Object.defineProperty(cipher.login, "hasFido2Credentials", {
get: jest.fn().mockReturnValue(true),
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
await component.addCredentialToCipher(cipher);
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "overwritePasskey" },
content: { key: "alreadyContainsPasskey" },
type: "warning",
});
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
});
it("should not add passkey when user cancels overwrite dialog", async () => {
const cipher = createMockCiphers()[0];
Object.defineProperty(cipher.login, "hasFido2Credentials", {
get: jest.fn().mockReturnValue(true),
});
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await component.addCredentialToCipher(cipher);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
});
});
describe("confirmPasskey", () => {
beforeEach(() => {
component.session = mockSession;
});
it("should confirm passkey creation successfully", async () => {
await component.confirmPasskey();
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true);
});
it("should call openSimpleDialog when session is null", async () => {
component.session = null;
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await component.confirmPasskey();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "unableToSavePasskey" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
acceptAction: expect.any(Function),
cancelButtonText: null,
});
});
});
describe("closeModal", () => {
it("should close modal and notify session", async () => {
component.session = mockSession;
await component.closeModal();
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -0,0 +1,219 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import { combineLatest, map, Observable, Subject, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DialogService,
BadgeModule,
ButtonModule,
DialogModule,
IconModule,
ItemModule,
SectionComponent,
TableModule,
SectionHeaderComponent,
BitIconButtonComponent,
SimpleDialogOptions,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
@Component({
standalone: true,
imports: [
CommonModule,
RouterModule,
SectionHeaderComponent,
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
ButtonModule,
DialogModule,
SectionComponent,
ItemModule,
BadgeModule,
],
templateUrl: "fido2-create.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2CreateComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
ciphers$: Observable<CipherView[]>;
private destroy$ = new Subject<void>();
readonly Icons = { BitwardenShield, NoResults };
private get DIALOG_MESSAGES() {
return {
unexpectedErrorShort: {
title: { key: "unexpectedErrorShort" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
cancelButtonText: null as null,
acceptAction: async () => this.dialogService.closeAll(),
},
unableToSavePasskey: {
title: { key: "unableToSavePasskey" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
cancelButtonText: null as null,
acceptAction: async () => this.dialogService.closeAll(),
},
overwritePasskey: {
title: { key: "overwritePasskey" },
content: { key: "alreadyContainsPasskey" },
type: "warning",
},
} as const satisfies Record<string, SimpleDialogOptions>;
}
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly accountService: AccountService,
private readonly cipherService: CipherService,
private readonly desktopAutofillService: DesktopAutofillService,
private readonly dialogService: DialogService,
private readonly domainSettingsService: DomainSettingsService,
private readonly passwordRepromptService: PasswordRepromptService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
if (this.session) {
const rpid = await this.session.getRpId();
this.initializeCiphersObservable(rpid);
} else {
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
}
}
async ngOnDestroy(): Promise<void> {
this.destroy$.next();
this.destroy$.complete();
await this.closeModal();
}
async addCredentialToCipher(cipher: CipherView): Promise<void> {
const isConfirmed = await this.validateCipherAccess(cipher);
try {
if (!this.session) {
throw new Error("Missing session");
}
this.session.notifyConfirmCreateCredential(isConfirmed, cipher);
} catch {
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
return;
}
await this.closeModal();
}
async confirmPasskey(): Promise<void> {
try {
if (!this.session) {
throw new Error("Missing session");
}
this.session.notifyConfirmCreateCredential(true);
} catch {
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
}
await this.closeModal();
}
async closeModal(): Promise<void> {
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
if (this.session) {
this.session.notifyConfirmCreateCredential(false);
this.session.confirmChosenCipher(null);
}
await this.router.navigate(["/"]);
}
private initializeCiphersObservable(rpid: string): void {
const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest;
if (!lastRegistrationRequest || !rpid) {
return;
}
const userHandle = Fido2Utils.bufferToString(
new Uint8Array(lastRegistrationRequest.userHandle),
);
this.ciphers$ = combineLatest([
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.domainSettingsService.getUrlEquivalentDomains(rpid),
]).pipe(
switchMap(async ([activeUserId, equivalentDomains]) => {
if (!activeUserId) {
return [];
}
try {
const allCiphers = await this.cipherService.getAllDecrypted(activeUserId);
return allCiphers.filter(
(cipher) =>
cipher != null &&
cipher.type == CipherType.Login &&
cipher.login?.matchesUri(rpid, equivalentDomains) &&
Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) &&
!cipher.deletedDate,
);
} catch {
await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort);
return [];
}
}),
);
}
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
if (cipher.login.hasFido2Credentials) {
const overwriteConfirmed = await this.dialogService.openSimpleDialog(
this.DIALOG_MESSAGES.overwritePasskey,
);
if (!overwriteConfirmed) {
return false;
}
}
if (cipher.reprompt) {
return this.passwordRepromptService.showPasswordPrompt();
}
return true;
}
private async showErrorDialog(config: SimpleDialogOptions): Promise<void> {
await this.dialogService.openSimpleDialog(config);
await this.closeModal();
}
}

View File

@@ -0,0 +1,44 @@
<div class="tw-flex tw-flex-col tw-h-full">
<bit-section
disableMargin
class="tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
(click)="closeModal()"
[label]="'close' | i18n"
>
{{ "close" | i18n }}
</button>
</bit-section-header>
</bit-section>
<div class="tw-h-full tw-items-start">
<bit-section
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
<div class="tw-flex tw-flex-col tw-gap-2">
<b>{{ "passkeyAlreadyExists" | i18n }}</b>
{{ "applicationDoesNotSupportDuplicates" | i18n }}
</div>
<button bitButton type="button" buttonType="primary" (click)="closeModal()">
{{ "close" | i18n }}
</button>
</div>
</bit-section>
</div>
</div>

View File

@@ -0,0 +1,78 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component";
describe("Fido2ExcludedCiphersComponent", () => {
let component: Fido2ExcludedCiphersComponent;
let fixture: ComponentFixture<Fido2ExcludedCiphersComponent>;
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
let mockAccountService: MockProxy<AccountService>;
let mockRouter: MockProxy<Router>;
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
let mockI18nService: MockProxy<I18nService>;
beforeEach(async () => {
mockDesktopSettingsService = mock<DesktopSettingsService>();
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
mockAccountService = mock<AccountService>();
mockRouter = mock<Router>();
mockSession = mock<DesktopFido2UserInterfaceSession>();
mockI18nService = mock<I18nService>();
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
await TestBed.configureTestingModule({
imports: [Fido2ExcludedCiphersComponent],
providers: [
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(Fido2ExcludedCiphersComponent);
component = fixture.componentInstance;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("ngOnInit", () => {
it("should initialize session", async () => {
await component.ngOnInit();
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
expect(component.session).toBe(mockSession);
});
});
describe("closeModal", () => {
it("should close modal and notify session when session exists", async () => {
component.session = mockSession;
await component.closeModal();
expect(mockDesktopSettingsService.setModalMode).toHaveBeenCalledWith(false);
expect(mockAccountService.setShowHeader).toHaveBeenCalledWith(true);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
});
});
});

View File

@@ -0,0 +1,78 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
BadgeModule,
ButtonModule,
DialogModule,
IconModule,
ItemModule,
SectionComponent,
TableModule,
SectionHeaderComponent,
BitIconButtonComponent,
} from "@bitwarden/components";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
@Component({
standalone: true,
imports: [
CommonModule,
RouterModule,
SectionHeaderComponent,
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
ButtonModule,
DialogModule,
SectionComponent,
ItemModule,
BadgeModule,
],
templateUrl: "fido2-excluded-ciphers.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
readonly Icons = { BitwardenShield, NoResults };
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly accountService: AccountService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
}
async ngOnDestroy(): Promise<void> {
await this.closeModal();
}
async closeModal(): Promise<void> {
// Clean up modal state
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
// Clean up session state
if (this.session) {
this.session.notifyConfirmCreateCredential(false);
this.session.confirmChosenCipher(null);
}
// Navigate away
await this.router.navigate(["/"]);
}
}

View File

@@ -0,0 +1,37 @@
<div class="tw-flex tw-flex-col tw-h-full">
<bit-section
disableMargin
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="tw-app-region-drag tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">{{ "passkeyLogin" | i18n }}</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
(click)="closeModal()"
[label]="'close' | i18n"
>
{{ "close" | i18n }}
</button>
</bit-section-header>
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col tw-grow">
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="chooseCipher(c)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
<button bitLink [title]="c.name" type="button">
{{ c.name }}
</button>
<span slot="secondary">{{ c.subTitle }}</span>
<span bitBadge slot="end">{{ "select" | i18n }}</span>
</button>
</bit-item>
</bit-section>
</div>

View File

@@ -0,0 +1,196 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
import { Fido2VaultComponent } from "./fido2-vault.component";
describe("Fido2VaultComponent", () => {
let component: Fido2VaultComponent;
let fixture: ComponentFixture<Fido2VaultComponent>;
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
let mockCipherService: MockProxy<CipherService>;
let mockAccountService: MockProxy<AccountService>;
let mockLogService: MockProxy<LogService>;
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
let mockRouter: MockProxy<Router>;
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
let mockI18nService: MockProxy<I18nService>;
const mockActiveAccount = { id: "test-user-id", email: "test@example.com" };
const mockCipherIds = ["cipher-1", "cipher-2", "cipher-3"];
beforeEach(async () => {
mockDesktopSettingsService = mock<DesktopSettingsService>();
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
mockCipherService = mock<CipherService>();
mockAccountService = mock<AccountService>();
mockLogService = mock<LogService>();
mockPasswordRepromptService = mock<PasswordRepromptService>();
mockRouter = mock<Router>();
mockSession = mock<DesktopFido2UserInterfaceSession>();
mockI18nService = mock<I18nService>();
mockAccountService.activeAccount$ = of(mockActiveAccount as Account);
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
mockSession.availableCipherIds$ = of(mockCipherIds);
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([]));
await TestBed.configureTestingModule({
imports: [Fido2VaultComponent],
providers: [
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
{ provide: CipherService, useValue: mockCipherService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: LogService, useValue: mockLogService },
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(Fido2VaultComponent);
component = fixture.componentInstance;
});
const mockCiphers: any[] = [
{
id: "cipher-1",
name: "Test Cipher 1",
type: CipherType.Login,
login: {
username: "test1@example.com",
},
reprompt: CipherRepromptType.None,
deletedDate: null,
},
{
id: "cipher-2",
name: "Test Cipher 2",
type: CipherType.Login,
login: {
username: "test2@example.com",
},
reprompt: CipherRepromptType.None,
deletedDate: null,
},
{
id: "cipher-3",
name: "Test Cipher 3",
type: CipherType.Login,
login: {
username: "test3@example.com",
},
reprompt: CipherRepromptType.Password,
deletedDate: null,
},
];
describe("ngOnInit", () => {
it("should initialize session and load ciphers successfully", async () => {
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(mockCiphers));
await component.ngOnInit();
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
expect(component.session).toBe(mockSession);
expect(component.cipherIds$).toBe(mockSession.availableCipherIds$);
expect(mockCipherService.cipherListViews$).toHaveBeenCalledWith(mockActiveAccount.id);
});
it("should handle when no active session found", async () => {
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
await component.ngOnInit();
expect(component.session).toBeNull();
});
it("should filter out deleted ciphers", async () => {
const ciphersWithDeleted = [
...mockCiphers.slice(0, 1),
{ ...mockCiphers[1], deletedDate: new Date() },
...mockCiphers.slice(2),
];
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(ciphersWithDeleted));
await component.ngOnInit();
await new Promise((resolve) => setTimeout(resolve, 0));
let ciphersResult: CipherView[] = [];
component.ciphers$.subscribe((ciphers) => {
ciphersResult = ciphers;
});
expect(ciphersResult).toHaveLength(2);
expect(ciphersResult.every((cipher) => !cipher.deletedDate)).toBe(true);
});
});
describe("chooseCipher", () => {
const cipher = mockCiphers[0];
beforeEach(() => {
component.session = mockSession;
});
it("should choose cipher when access is validated", async () => {
cipher.reprompt = CipherRepromptType.None;
await component.chooseCipher(cipher);
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
});
it("should prompt for password when cipher requires reprompt", async () => {
cipher.reprompt = CipherRepromptType.Password;
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(true);
await component.chooseCipher(cipher);
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
});
it("should not choose cipher when password reprompt is cancelled", async () => {
cipher.reprompt = CipherRepromptType.Password;
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
await component.chooseCipher(cipher);
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, false);
});
});
describe("closeModal", () => {
it("should close modal and notify session", async () => {
component.session = mockSession;
await component.closeModal();
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -0,0 +1,161 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import {
firstValueFrom,
map,
combineLatest,
of,
BehaviorSubject,
Observable,
Subject,
takeUntil,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
ButtonModule,
DialogModule,
DialogService,
IconModule,
ItemModule,
SectionComponent,
TableModule,
BitIconButtonComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../services/desktop-fido2-user-interface.service";
@Component({
standalone: true,
imports: [
CommonModule,
RouterModule,
SectionHeaderComponent,
BitIconButtonComponent,
TableModule,
JslibModule,
IconModule,
ButtonModule,
DialogModule,
SectionComponent,
ItemModule,
BadgeModule,
],
templateUrl: "fido2-vault.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2VaultComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private destroy$ = new Subject<void>();
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
cipherIds$: Observable<string[]> | undefined;
readonly Icons = { BitwardenShield };
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
private readonly accountService: AccountService,
private readonly dialogService: DialogService,
private readonly logService: LogService,
private readonly passwordRepromptService: PasswordRepromptService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
this.cipherIds$ = this.session?.availableCipherIds$;
await this.loadCiphers();
}
async ngOnDestroy(): Promise<void> {
this.destroy$.next();
this.destroy$.complete();
}
async chooseCipher(cipher: CipherView): Promise<void> {
if (!this.session) {
await this.dialogService.openSimpleDialog({
title: { key: "unexpectedErrorShort" },
content: { key: "closeThisBitwardenWindow" },
type: "danger",
acceptButtonText: { key: "closeThisWindow" },
cancelButtonText: null,
});
await this.closeModal();
return;
}
const isConfirmed = await this.validateCipherAccess(cipher);
this.session.confirmChosenCipher(cipher.id, isConfirmed);
await this.closeModal();
}
async closeModal(): Promise<void> {
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
if (this.session) {
this.session.notifyConfirmCreateCredential(false);
this.session.confirmChosenCipher(null);
}
await this.router.navigate(["/"]);
}
private async loadCiphers(): Promise<void> {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!activeUserId) {
return;
}
// Combine cipher list with optional cipher IDs filter
combineLatest([this.cipherService.cipherListViews$(activeUserId), this.cipherIds$ || of(null)])
.pipe(
map(([ciphers, cipherIds]) => {
// Filter out deleted ciphers
const activeCiphers = ciphers.filter((cipher) => !cipher.deletedDate);
// If specific IDs provided, filter by them
if (cipherIds?.length > 0) {
return activeCiphers.filter((cipher) => cipherIds.includes(cipher.id as string));
}
return activeCiphers;
}),
takeUntil(this.destroy$),
)
.subscribe({
next: (ciphers) => this.ciphersSubject.next(ciphers as CipherView[]),
error: (error: unknown) => this.logService.error("Failed to load ciphers", error),
});
}
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
if (cipher.reprompt !== CipherRepromptType.None) {
return this.passwordRepromptService.showPasswordPrompt();
}
return true;
}
}

View File

@@ -12,6 +12,8 @@ export default {
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
ipcRenderer.invoke("autofill.runCommand", params),
listenerReady: () => ipcRenderer.send("autofill.listenerReady"),
listenPasskeyRegistration: (
fn: (
clientId: number,
@@ -130,6 +132,25 @@ export default {
},
);
},
listenNativeStatus: (
fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void,
) => {
ipcRenderer.on(
"autofill.nativeStatus",
(
event,
data: {
clientId: number;
sequenceNumber: number;
status: { key: string; value: string };
},
) => {
const { clientId, sequenceNumber, status } = data;
fn(clientId, sequenceNumber, status);
},
);
},
configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => {
ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut });
},

View File

@@ -1,6 +1,8 @@
import { Injectable, OnDestroy } from "@angular/core";
import {
Subject,
combineLatest,
debounceTime,
distinctUntilChanged,
filter,
firstValueFrom,
@@ -8,10 +10,11 @@ import {
mergeMap,
switchMap,
takeUntil,
EMPTY,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -48,6 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
private registrationRequest: autofill.PasskeyRegistrationRequest;
constructor(
private logService: LogService,
@@ -55,6 +59,7 @@ export class DesktopAutofillService implements OnDestroy {
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
private authService: AuthService,
private platformUtilsService: PlatformUtilsService,
) {}
@@ -68,28 +73,56 @@ export class DesktopAutofillService implements OnDestroy {
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.pipe(
distinctUntilChanged(),
switchMap((enabled) => {
if (!enabled) {
return EMPTY;
}
return this.accountService.activeAccount$.pipe(
filter((enabled) => enabled === true), // Only proceed if feature is enabled
switchMap(() => {
return combineLatest([
this.accountService.activeAccount$.pipe(
map((account) => account?.id),
filter((userId): userId is UserId => userId != null),
switchMap((userId) => this.cipherService.cipherViews$(userId)),
),
this.authService.activeAccountStatus$,
]).pipe(
// Only proceed when the vault is unlocked
filter(([, status]) => status === AuthenticationStatus.Unlocked),
// Then get cipher views
switchMap(([userId]) => this.cipherService.cipherViews$(userId)),
);
}),
// TODO: This will unset all the autofill credentials on the OS
// when the account locks. We should instead explicilty clear the credentials
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead.
debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change)
// No filter for empty arrays here - we want to sync even if there are 0 items
filter((cipherViewMap) => cipherViewMap !== null),
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
takeUntil(this.destroy$),
)
.subscribe();
// Listen for sign out to clear credentials
this.authService.activeAccountStatus$
.pipe(
filter((status) => status === AuthenticationStatus.LoggedOut),
mergeMap(() => this.sync([])), // sync an empty array
takeUntil(this.destroy$),
)
.subscribe();
this.listenIpc();
}
async adHocSync(): Promise<any> {
this.logService.debug("Performing AdHoc sync");
const account = await firstValueFrom(this.accountService.activeAccount$);
const userId = account?.id;
if (!userId) {
throw new Error("No active user found");
}
const cipherViewMap = await firstValueFrom(this.cipherService.cipherViews$(userId));
this.logService.info("Performing AdHoc sync", Object.values(cipherViewMap ?? []));
await this.sync(Object.values(cipherViewMap ?? []));
}
/** Give metadata about all available credentials in the users vault */
async sync(cipherViews: CipherView[]) {
const status = await this.status();
@@ -130,6 +163,11 @@ export class DesktopAutofillService implements OnDestroy {
}));
}
this.logService.info("Syncing autofill credentials", {
fido2Credentials,
passwordCredentials,
});
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
namespace: "autofill",
command: "sync",
@@ -155,39 +193,61 @@ export class DesktopAutofillService implements OnDestroy {
});
}
get lastRegistrationRequest() {
return this.registrationRequest;
}
listenIpc() {
ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request);
this.logService.warning(
"listenPasskeyRegistration2",
this.convertRegistrationRequest(request),
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled",
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
return;
}
this.registrationRequest = request;
this.logService.debug("listenPasskeyRegistration", clientId, sequenceNumber, request);
this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request));
const controller = new AbortController();
void this.fido2AuthenticatorService
.makeCredential(
try {
const response = await this.fido2AuthenticatorService.makeCredential(
this.convertRegistrationRequest(request),
{ windowXy: request.windowXy },
{ windowXy: normalizePosition(request.windowXy) },
controller,
)
.then((response) => {
);
callback(null, this.convertRegistrationResponse(request, response));
})
.catch((error) => {
} catch (error) {
this.logService.error("listenPasskeyRegistration error", error);
callback(error, null);
});
}
});
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
this.logService.warning(
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled",
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
return;
}
this.logService.debug(
"listenPasskeyAssertion without user interface",
clientId,
sequenceNumber,
request,
);
const controller = new AbortController();
try {
// For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
if (request.recordIdentifier && request.credentialId.length === 0) {
@@ -221,41 +281,64 @@ export class DesktopAutofillService implements OnDestroy {
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request, true),
{ windowXy: normalizePosition(request.windowXy) },
controller,
)
.then((response) => {
);
callback(null, this.convertAssertionResponse(request, response));
})
.catch((error) => {
} catch (error) {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
});
return;
}
},
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled",
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
return;
}
this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request);
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(
try {
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
{ windowXy: normalizePosition(request.windowXy) },
controller,
)
.then((response) => {
);
callback(null, this.convertAssertionResponse(request, response));
})
.catch((error) => {
} catch (error) {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
}
});
// Listen for native status messages
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
this.logService.debug(
"listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled",
);
return;
}
this.logService.info("Received native status", status.key, status.value);
if (status.key === "request-sync") {
// perform ad-hoc sync
await this.adHocSync();
}
});
ipc.autofill.listenerReady();
}
private convertRegistrationRequest(
@@ -277,7 +360,10 @@ export class DesktopAutofillService implements OnDestroy {
alg,
type: "public-key",
})),
excludeCredentialDescriptorList: [],
excludeCredentialDescriptorList: request.excludedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
type: "public-key" as const,
})),
requireResidentKey: true,
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
@@ -309,18 +395,19 @@ export class DesktopAutofillService implements OnDestroy {
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
assumeUserPresence: boolean = false,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
allowedCredentials = [
{
id: new Uint8Array(request.credentialId),
id: new Uint8Array(request.credentialId).buffer,
type: "public-key" as const,
},
];
} else {
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
id: new Uint8Array(credentialId).buffer,
type: "public-key" as const,
}));
}
@@ -333,7 +420,7 @@ export class DesktopAutofillService implements OnDestroy {
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
assumeUserPresence,
};
}
@@ -358,3 +445,13 @@ export class DesktopAutofillService implements OnDestroy {
this.destroy$.complete();
}
}
function normalizePosition(position: { x: number; y: number }): { x: number; y: number } {
// Add 100 pixels to the x-coordinate to offset the native OS dialog positioning.
const xPositionOffset = 100;
return {
x: Math.round(position.x + xPositionOffset),
y: Math.round(position.y),
};
}

View File

@@ -66,7 +66,7 @@ export class DesktopFido2UserInterfaceService
nativeWindowObject: NativeWindowObject,
abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> {
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject);
this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject);
const session = new DesktopFido2UserInterfaceSession(
this.authService,
this.cipherService,
@@ -94,9 +94,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
) {}
private confirmCredentialSubject = new Subject<boolean>();
private createdCipher: Cipher;
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null);
private updatedCipher: CipherView;
private rpId = new BehaviorSubject<string>(null);
private availableCipherIdsSubject = new BehaviorSubject<string[]>([""]);
/**
* Observable that emits available cipher IDs once they're confirmed by the UI
*/
@@ -114,7 +116,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
assumeUserPresence,
masterPasswordRepromptRequired,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential desktop function", {
this.logService.debug("pickCredential desktop function", {
cipherIds,
userVerification,
assumeUserPresence,
@@ -123,6 +125,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
try {
// Check if we can return the credential without user interaction
await this.accountService.setShowHeader(false);
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
this.logService.debug(
"shortcut - Assuming user presence and returning cipherId",
@@ -136,22 +139,27 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
// make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(cipherIds);
await this.showUi("/passkeys", this.windowObject.windowXy);
await this.showUi("/fido2-assertion", this.windowObject.windowXy, false);
const chosenCipherResponse = await this.waitForUiChosenCipher();
this.logService.debug("Received chosen cipher", chosenCipherResponse);
return {
cipherId: chosenCipherResponse.cipherId,
userVerified: chosenCipherResponse.userVerified,
cipherId: chosenCipherResponse?.cipherId,
userVerified: chosenCipherResponse?.userVerified,
};
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
}
}
async getRpId(): Promise<string> {
return firstValueFrom(this.rpId.pipe(filter((id) => id != null)));
}
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
this.chosenCipherSubject.next({ cipherId, userVerified });
this.chosenCipherSubject.complete();
@@ -159,7 +167,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private async waitForUiChosenCipher(
timeoutMs: number = 60000,
): Promise<{ cipherId: string; userVerified: boolean } | undefined> {
): Promise<{ cipherId?: string; userVerified: boolean } | undefined> {
try {
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
} catch {
@@ -174,7 +182,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
/**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/
notifyConfirmNewCredential(confirmed: boolean): void {
notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void {
if (updatedCipher) {
this.updatedCipher = updatedCipher;
}
this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete();
}
@@ -195,60 +206,79 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
async confirmNewCredential({
credentialName,
userName,
userHandle,
userVerification,
rpId,
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning(
this.logService.debug(
"confirmNewCredential",
credentialName,
userName,
userHandle,
userVerification,
rpId,
);
this.rpId.next(rpId);
try {
await this.showUi("/passkeys", this.windowObject.windowXy);
await this.showUi("/fido2-creation", this.windowObject.windowXy, false);
// Wait for the UI to wrap up
const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) {
return { cipherId: undefined, userVerified: false };
}
// Create the credential
await this.createCredential({
if (this.updatedCipher) {
await this.updateCredential(this.updatedCipher);
return { cipherId: this.updatedCipher.id, userVerified: userVerification };
} else {
// Create the cipher
const createdCipher = await this.createCipher({
credentialName,
userName,
rpId,
userHandle: "",
userHandle,
userVerification,
});
// wait for 10ms to help RXJS catch up(?)
// We sometimes get a race condition from this.createCredential not updating cipherService in time
//console.log("waiting 10ms..");
//await new Promise((resolve) => setTimeout(resolve, 10));
//console.log("Just waited 10ms");
// Return the new cipher (this.createdCipher)
return { cipherId: this.createdCipher.id, userVerified: userVerification };
return { cipherId: createdCipher.id, userVerified: userVerification };
}
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
await this.accountService.setShowHeader(true);
}
}
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> {
private async hideUi(): Promise<void> {
await this.desktopSettingsService.setModalMode(false);
await this.router.navigate(["/"]);
}
private async showUi(
route: string,
position?: { x: number; y: number },
showTrafficButtons: boolean = false,
disableRedirect?: boolean,
): Promise<void> {
// Load the UI:
await this.desktopSettingsService.setModalMode(true, position);
await this.router.navigate(["/passkeys"]);
await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position);
await this.accountService.setShowHeader(showTrafficButtons);
await this.router.navigate([
route,
{
"disable-redirect": disableRedirect || null,
},
]);
}
/**
* Can be called by the UI to create a new credential with user input etc.
* Can be called by the UI to create a new cipher with user input etc.
* @param param0
*/
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
// Store the passkey on a new cipher to avoid replacing something important
const cipher = new CipherView();
cipher.name = credentialName;
@@ -267,32 +297,81 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!activeUserId) {
throw new Error("No active user ID found!");
}
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
try {
const createdCipher = await this.cipherService.createWithServer(encCipher);
this.createdCipher = createdCipher;
return createdCipher;
} catch {
throw new Error("Unable to create cipher");
}
}
async updateCredential(cipher: CipherView): Promise<void> {
this.logService.info("updateCredential");
await firstValueFrom(
this.accountService.activeAccount$.pipe(
map(async (a) => {
if (a) {
const encCipher = await this.cipherService.encrypt(cipher, a.id);
await this.cipherService.updateWithServer(encCipher);
}
}),
),
);
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
this.logService.warning("informExcludedCredential", existingCipherIds);
this.logService.debug("informExcludedCredential", existingCipherIds);
// make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(existingCipherIds);
await this.accountService.setShowHeader(false);
await this.showUi("/fido2-excluded", this.windowObject.windowXy, false);
}
async ensureUnlockedVault(): Promise<void> {
this.logService.warning("ensureUnlockedVault");
this.logService.debug("ensureUnlockedVault");
const status = await firstValueFrom(this.authService.activeAccountStatus$);
if (status !== AuthenticationStatus.Unlocked) {
await this.showUi("/lock", this.windowObject.windowXy, true, true);
let status2: AuthenticationStatus;
try {
status2 = await lastValueFrom(
this.authService.activeAccountStatus$.pipe(
filter((s) => s === AuthenticationStatus.Unlocked),
take(1),
timeout(1000 * 60 * 5), // 5 minutes
),
);
} catch (error) {
this.logService.warning("Error while waiting for vault to unlock", error);
}
if (status2 === AuthenticationStatus.Unlocked) {
await this.router.navigate(["/"]);
}
if (status2 !== AuthenticationStatus.Unlocked) {
await this.hideUi();
throw new Error("Vault is not unlocked");
}
}
}
async informCredentialNotFound(): Promise<void> {
this.logService.warning("informCredentialNotFound");
this.logService.debug("informCredentialNotFound");
}
async close() {
this.logService.warning("close");
this.logService.debug("close");
}
}

View File

@@ -708,6 +708,18 @@
"addAttachment": {
"message": "Add attachment"
},
"itemsTransferred": {
"message": "Items transferred"
},
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"maxFileSizeSansPunctuation": {
"message": "Maximum file size is 500 MB"
},
@@ -908,6 +920,12 @@
"unexpectedError": {
"message": "An unexpected error has occurred."
},
"unexpectedErrorShort": {
"message": "Unexpected error"
},
"closeThisBitwardenWindow": {
"message": "Close this Bitwarden window and try again."
},
"itemInformation": {
"message": "Item information"
},
@@ -3886,6 +3904,75 @@
"fileSavedToDevice": {
"message": "File saved to device. Manage from your device downloads."
},
"importantNotice": {
"message": "Important notice"
},
"setupTwoStepLogin": {
"message": "Set up two-step login"
},
"newDeviceVerificationNoticeContentPage1": {
"message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025."
},
"newDeviceVerificationNoticeContentPage2": {
"message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access."
},
"remindMeLater": {
"message": "Remind me later"
},
"newDeviceVerificationNoticePageOneFormContent": {
"message": "Do you have reliable access to your email, $EMAIL$?",
"placeholders": {
"email": {
"content": "$1",
"example": "your_name@email.com"
}
}
},
"newDeviceVerificationNoticePageOneEmailAccessNo": {
"message": "No, I do not"
},
"newDeviceVerificationNoticePageOneEmailAccessYes": {
"message": "Yes, I can reliably access my email"
},
"turnOnTwoStepLogin": {
"message": "Turn on two-step login"
},
"changeAcctEmail": {
"message": "Change account email"
},
"passkeyLogin": {
"message": "Log in with passkey?"
},
"savePasskeyQuestion": {
"message": "Save passkey?"
},
"saveNewPasskey": {
"message": "Save as new login"
},
"savePasskeyNewLogin": {
"message": "Save passkey as new login"
},
"noMatchingLoginsForSite": {
"message": "No matching logins for this site"
},
"overwritePasskey": {
"message": "Overwrite passkey?"
},
"unableToSavePasskey": {
"message": "Unable to save passkey"
},
"alreadyContainsPasskey": {
"message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"
},
"passkeyAlreadyExists": {
"message": "A passkey already exists for this application."
},
"applicationDoesNotSupportDuplicates": {
"message": "This application does not support duplicates."
},
"closeThisWindow": {
"message": "Close this window"
},
"allowScreenshots": {
"message": "Allow screen capture"
},
@@ -4244,8 +4331,8 @@
"andMoreFeatures": {
"message": "And more!"
},
"planDescPremium": {
"message": "Complete online security"
"advancedOnlineSecurity": {
"message": "Advanced online security"
},
"upgradeToPremium": {
"message": "Upgrade to Premium"
@@ -4296,5 +4383,56 @@
},
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
},
"upgrade": {
"message": "Upgrade"
},
"leaveConfirmationDialogTitle": {
"message": "Are you sure you want to leave?"
},
"leaveConfirmationDialogContentOne": {
"message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
},
"leaveConfirmationDialogContentTwo": {
"message": "Contact your admin to regain access."
},
"leaveConfirmationDialogConfirmButton": {
"message": "Leave $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"howToManageMyVault": {
"message": "How do I manage my vault?"
},
"transferItemsToOrganizationTitle": {
"message": "Transfer items to $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"transferItemsToOrganizationContent": {
"message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"acceptTransfer": {
"message": "Accept transfer"
},
"declineAndLeave": {
"message": "Decline and leave"
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
}
}

View File

@@ -14,7 +14,7 @@ import { isDev } from "../utils";
import { WindowMain } from "./window.main";
export class NativeMessagingMain {
private ipcServer: ipc.IpcServer | null;
private ipcServer: ipc.NativeIpcServer | null;
private connected: number[] = [];
constructor(
@@ -78,7 +78,7 @@ export class NativeMessagingMain {
this.ipcServer.stop();
}
this.ipcServer = await ipc.IpcServer.listen("bw", (error, msg) => {
this.ipcServer = await ipc.NativeIpcServer.listen("bw", (error, msg) => {
switch (msg.kind) {
case ipc.IpcMessageType.Connected: {
this.connected.push(msg.clientId);

View File

@@ -53,9 +53,14 @@ export class TrayMain {
},
{
visible: isDev(),
label: "Fake Popup",
label: "Fake Popup Select",
click: () => this.fakePopup(),
},
{
visible: isDev(),
label: "Fake Popup Create",
click: () => this.fakePopupCreate(),
},
{ type: "separator" },
{
label: this.i18nService.t("exit"),
@@ -218,4 +223,8 @@ export class TrayMain {
private async fakePopup() {
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
}
private async fakePopupCreate() {
await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true });
}
}

View File

@@ -100,10 +100,10 @@ export class WindowMain {
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
this.win.hide();
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) {
} else if (newValue.isModalModeActive) {
// Apply the popup modal styles
this.logService.info("Applying popup modal styles", newValue.modalPosition);
applyPopupModalStyles(this.win, newValue.modalPosition);
applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition);
this.win.show();
}
}),
@@ -273,7 +273,7 @@ export class WindowMain {
this.win = new BrowserWindow({
width: this.windowStates[mainWindowSizeKey].width,
height: this.windowStates[mainWindowSizeKey].height,
minWidth: 680,
minWidth: 600,
minHeight: 500,
x: this.windowStates[mainWindowSizeKey].x,
y: this.windowStates[mainWindowSizeKey].y,

View File

@@ -7,6 +7,11 @@ import { WindowMain } from "../../../main/window.main";
import { CommandDefinition } from "./command";
type BufferedMessage = {
channel: string;
data: any;
};
export type RunCommandParams<C extends CommandDefinition> = {
namespace: C["namespace"];
command: C["name"];
@@ -16,13 +21,44 @@ export type RunCommandParams<C extends CommandDefinition> = {
export type RunCommandResult<C extends CommandDefinition> = C["output"];
export class NativeAutofillMain {
private ipcServer: autofill.IpcServer | null;
private ipcServer?: autofill.AutofillIpcServer;
private messageBuffer: BufferedMessage[] = [];
private listenerReady = false;
constructor(
private logService: LogService,
private windowMain: WindowMain,
) {}
/**
* Safely sends a message to the renderer, buffering it if the server isn't ready yet
*/
private safeSend(channel: string, data: any) {
if (this.listenerReady && this.windowMain.win?.webContents) {
this.windowMain.win.webContents.send(channel, data);
} else {
this.messageBuffer.push({ channel, data });
}
}
/**
* Flushes all buffered messages to the renderer
*/
private flushMessageBuffer() {
if (!this.windowMain.win?.webContents) {
this.logService.error("Cannot flush message buffer - window not available");
return;
}
this.logService.info(`Flushing ${this.messageBuffer.length} buffered messages`);
for (const { channel, data } of this.messageBuffer) {
this.windowMain.win.webContents.send(channel, data);
}
this.messageBuffer = [];
}
async init() {
ipcMain.handle(
"autofill.runCommand",
@@ -34,16 +70,16 @@ export class NativeAutofillMain {
},
);
this.ipcServer = await autofill.IpcServer.listen(
this.ipcServer = await autofill.AutofillIpcServer.listen(
"af",
// RegistrationCallback
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.registration", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
this.ipcServer?.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
this.safeSend("autofill.passkeyRegistration", {
clientId,
sequenceNumber,
request,
@@ -53,10 +89,10 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
this.ipcServer?.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
this.safeSend("autofill.passkeyAssertion", {
clientId,
sequenceNumber,
request,
@@ -66,33 +102,54 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
this.ipcServer?.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", {
this.safeSend("autofill.passkeyAssertionWithoutUserInterface", {
clientId,
sequenceNumber,
request,
});
},
// NativeStatusCallback
(error, clientId, sequenceNumber, status) => {
if (error) {
this.logService.error("autofill.IpcServer.nativeStatus", error);
this.ipcServer?.completeError(clientId, sequenceNumber, String(error));
return;
}
this.safeSend("autofill.nativeStatus", {
clientId,
sequenceNumber,
status,
});
},
);
ipcMain.on("autofill.listenerReady", () => {
this.listenerReady = true;
this.logService.info(
`Listener is ready, flushing ${this.messageBuffer.length} buffered messages`,
);
this.flushMessageBuffer();
});
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
this.logService.warning("autofill.completePasskeyRegistration", data);
this.logService.debug("autofill.completePasskeyRegistration", data);
const { clientId, sequenceNumber, response } = data;
this.ipcServer.completeRegistration(clientId, sequenceNumber, response);
this.ipcServer?.completeRegistration(clientId, sequenceNumber, response);
});
ipcMain.on("autofill.completePasskeyAssertion", (event, data) => {
this.logService.warning("autofill.completePasskeyAssertion", data);
this.logService.debug("autofill.completePasskeyAssertion", data);
const { clientId, sequenceNumber, response } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, response);
this.ipcServer?.completeAssertion(clientId, sequenceNumber, response);
});
ipcMain.on("autofill.completeError", (event, data) => {
this.logService.warning("autofill.completeError", data);
this.logService.debug("autofill.completeError", data);
const { clientId, sequenceNumber, error } = data;
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
this.ipcServer?.completeError(clientId, sequenceNumber, String(error));
});
}

Some files were not shown because too many files have changed in this diff Show More