diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e38277877bb..3bfbcb7672b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -102,6 +102,7 @@ apps/browser/src/autofill @bitwarden/team-autofill-dev
apps/desktop/src/autofill @bitwarden/team-autofill-dev
libs/common/src/autofill @bitwarden/team-autofill-dev
apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev
+apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev
diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts
index c7642638dc3..5980cae2a05 100644
--- a/apps/desktop/src/app/app-routing.module.ts
+++ b/apps/desktop/src/app/app-routing.module.ts
@@ -64,6 +64,7 @@ import { TwoFactorComponent } from "../auth/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VaultComponent } from "../vault/app/vault/vault.component";
+import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { SendComponent } from "./tools/send/send.component";
/**
@@ -331,6 +332,10 @@ const routes: Routes = [
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
+ {
+ path: "passkeys",
+ component: Fido2PlaceholderComponent,
+ },
{
path: "",
component: AnonLayoutWrapperComponent,
diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts
new file mode 100644
index 00000000000..b3302d63241
--- /dev/null
+++ b/apps/desktop/src/app/components/fido2placeholder.component.ts
@@ -0,0 +1,36 @@
+import { Component } from "@angular/core";
+import { Router } from "@angular/router";
+
+import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
+
+@Component({
+ standalone: true,
+ template: `
+
+
Select your passkey
+
+
+
+ `,
+})
+export class Fido2PlaceholderComponent {
+ constructor(
+ private readonly desktopSettingsService: DesktopSettingsService,
+ private readonly router: Router,
+ ) {}
+
+ async closeModal() {
+ await this.router.navigate(["/"]);
+ await this.desktopSettingsService.setInModalMode(false);
+ }
+}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index a4842249c93..c4ae8e1c103 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -272,6 +272,8 @@ export class Main {
this.migrationRunner.run().then(
async () => {
await this.toggleHardwareAcceleration();
+ // Reset modal mode to make sure main window is displayed correctly
+ await this.desktopSettingsService.resetInModalMode();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();
diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts
index 52a8615a1da..48ce292d238 100644
--- a/apps/desktop/src/main/tray.main.ts
+++ b/apps/desktop/src/main/tray.main.ts
@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as path from "path";
+import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs";
@@ -8,6 +9,7 @@ import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
+import { cleanUserAgent, isDev } from "../utils";
import { WindowMain } from "./window.main";
@@ -46,6 +48,11 @@ export class TrayMain {
label: this.i18nService.t("showHide"),
click: () => this.toggleWindow(),
},
+ {
+ visible: isDev(),
+ label: "Fake Popup",
+ click: () => this.fakePopup(),
+ },
{ type: "separator" },
{
label: this.i18nService.t("exit"),
@@ -183,7 +190,7 @@ export class TrayMain {
this.hideDock();
}
} else {
- this.windowMain.win.show();
+ this.windowMain.show();
if (this.isDarwin()) {
this.showDock();
}
@@ -196,4 +203,38 @@ export class TrayMain {
this.windowMain.win.close();
}
}
+
+ /**
+ * This method is used to test modal behavior during development and could be removed in the future.
+ * @returns
+ */
+ private async fakePopup() {
+ if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) {
+ await this.windowMain.createWindow("modal-app");
+ return;
+ }
+
+ // Restyle existing
+ const existingWin = this.windowMain.win;
+
+ await this.desktopSettingsService.setInModalMode(true);
+ await existingWin.loadURL(
+ url.format({
+ protocol: "file:",
+ //pathname: `${__dirname}/index.html`,
+ pathname: path.join(__dirname, "/index.html"),
+ slashes: true,
+ hash: "/passkeys",
+ query: {
+ redirectUrl: "/passkeys",
+ },
+ }),
+ {
+ userAgent: cleanUserAgent(existingWin.webContents.userAgent),
+ },
+ );
+ existingWin.once("ready-to-show", () => {
+ existingWin.show();
+ });
+ }
}
diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts
index 8fe17772072..f9b06890977 100644
--- a/apps/desktop/src/main/window.main.ts
+++ b/apps/desktop/src/main/window.main.ts
@@ -5,7 +5,7 @@ import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
-import { firstValueFrom } from "rxjs";
+import { concatMap, firstValueFrom, pairwise } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@@ -13,6 +13,7 @@ import { processisolations } from "@bitwarden/desktop-napi";
import { BiometricStateService } from "@bitwarden/key-management";
import { WindowState } from "../platform/models/domain/window-state";
+import { applyMainWindowStyles, applyPopupModalStyles } from "../platform/popup-modal-styles";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils";
@@ -76,6 +77,24 @@ export class WindowMain {
}
});
+ this.desktopSettingsService.inModalMode$
+ .pipe(
+ pairwise(),
+ concatMap(async ([lastValue, newValue]) => {
+ if (lastValue && !newValue) {
+ // Reset the window state to the main window state
+ 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 && newValue) {
+ // Apply the popup modal styles
+ applyPopupModalStyles(this.win);
+ this.win.show();
+ }
+ }),
+ )
+ .subscribe();
+
return new Promise((resolve, reject) => {
try {
if (!isMacAppStore()) {
@@ -175,7 +194,20 @@ export class WindowMain {
});
}
- async createWindow(): Promise {
+ /// Show the window with main window styles
+ show() {
+ if (this.win != null) {
+ applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
+ this.win.show();
+ }
+ }
+
+ /**
+ * Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded.
+ * When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded.
+ * TODO: We might want to refactor the template argument to accomodate more target pages, e.g. ssh-agent.
+ */
+ async createWindow(template: "full-app" | "modal-app" = "full-app"): Promise {
this.windowStates[mainWindowSizeKey] = await this.getWindowState(
this.defaultWidth,
this.defaultHeight,
@@ -209,6 +241,12 @@ export class WindowMain {
},
});
+ if (template === "modal-app") {
+ applyPopupModalStyles(this.win);
+ } else {
+ applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
+ }
+
this.win.webContents.on("dom-ready", () => {
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
});
@@ -218,21 +256,41 @@ export class WindowMain {
}
// Show it later since it might need to be maximized.
- this.win.show();
-
- // and load the index.html of the app.
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.win.loadURL(
- url.format({
- protocol: "file:",
- pathname: path.join(__dirname, "/index.html"),
- slashes: true,
- }),
- {
- userAgent: cleanUserAgent(this.win.webContents.userAgent),
- },
- );
+ // use once event to avoid flash on unstyled content.
+ this.win.once("ready-to-show", () => {
+ this.win.show();
+ });
+
+ if (template === "full-app") {
+ // and load the index.html of the app.
+ // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
+ void this.win.loadURL(
+ url.format({
+ protocol: "file:",
+ pathname: path.join(__dirname, "/index.html"),
+ slashes: true,
+ }),
+ {
+ userAgent: cleanUserAgent(this.win.webContents.userAgent),
+ },
+ );
+ } else {
+ // we're in modal mode - load the passkeys page
+ await this.win.loadURL(
+ url.format({
+ protocol: "file:",
+ pathname: path.join(__dirname, "/index.html"),
+ slashes: true,
+ hash: "/passkeys",
+ query: {
+ redirectUrl: "/passkeys",
+ },
+ }),
+ {
+ userAgent: cleanUserAgent(this.win.webContents.userAgent),
+ },
+ );
+ }
// Open the DevTools.
if (isDev()) {
@@ -319,6 +377,12 @@ export class WindowMain {
return;
}
+ const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
+
+ if (inModalMode) {
+ return;
+ }
+
try {
const bounds = win.getBounds();
@@ -329,9 +393,14 @@ export class WindowMain {
}
}
- this.windowStates[configKey].isMaximized = win.isMaximized();
+ // We treat fullscreen as maximized (would be even better to store isFullscreen as its own flag).
+ this.windowStates[configKey].isMaximized = win.isMaximized() || win.isFullScreen();
this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds;
+ // Maybe store these as well?
+ // win.isFocused();
+ // win.isVisible();
+
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
this.windowStates[configKey].x = bounds.x;
this.windowStates[configKey].y = bounds.y;
diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts
new file mode 100644
index 00000000000..9c3f06b34bf
--- /dev/null
+++ b/apps/desktop/src/platform/popup-modal-styles.ts
@@ -0,0 +1,52 @@
+import { BrowserWindow } from "electron";
+
+import { WindowState } from "./models/domain/window-state";
+
+// change as needed, however limited by mainwindow minimum size
+const popupWidth = 680;
+const popupHeight = 500;
+
+export function applyPopupModalStyles(window: BrowserWindow) {
+ window.unmaximize();
+ window.setSize(popupWidth, popupHeight);
+ window.center();
+ window.setWindowButtonVisibility?.(false);
+ window.setMenuBarVisibility?.(false);
+ window.setResizable(false);
+ window.setAlwaysOnTop(true);
+
+ // Adjusting from full screen is a bit more hassle
+ if (window.isFullScreen()) {
+ window.setFullScreen(false);
+ window.once("leave-full-screen", () => {
+ window.setSize(popupWidth, popupHeight);
+ window.center();
+ });
+ }
+}
+
+export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) {
+ window.setMinimumSize(680, 500);
+
+ // need to guard against null/undefined values
+
+ if (existingWindowState?.width && existingWindowState?.height) {
+ window.setSize(existingWindowState.width, existingWindowState.height);
+ }
+
+ if (existingWindowState?.x && existingWindowState?.y) {
+ window.setPosition(existingWindowState.x, existingWindowState.y);
+ }
+
+ window.setWindowButtonVisibility?.(true);
+ window.setMenuBarVisibility?.(true);
+ window.setResizable(true);
+ window.setAlwaysOnTop(false);
+
+ // We're currently not recovering the maximized state, mostly due to conflicts with hiding the window.
+ // window.setFullScreen(existingWindowState.isMaximized);
+
+ // if (existingWindowState.isMaximized) {
+ // window.maximize();
+ // }
+}
diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts
index c698e7d5b1b..77bd57cddb1 100644
--- a/apps/desktop/src/platform/services/desktop-settings.service.ts
+++ b/apps/desktop/src/platform/services/desktop-settings.service.ts
@@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear
});
+const IN_MODAL_MODE = new KeyDefinition(DESKTOP_SETTINGS_DISK, "inModalMode", {
+ deserializer: (b) => b,
+});
+
/**
* Various settings for controlling application behavior specific to the desktop client.
*/
@@ -155,6 +159,10 @@ export class DesktopSettingsService {
*/
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean));
+ private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE);
+
+ inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean));
+
constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe(
map((window) =>
@@ -163,6 +171,14 @@ export class DesktopSettingsService {
);
}
+ /**
+ * This is used to clear the setting on application start to make sure we don't end up
+ * stuck in modal mode if the application is force-closed in modal mode.
+ */
+ async resetInModalMode() {
+ await this.inModalModeState.update(() => false);
+ }
+
async setHardwareAcceleration(enabled: boolean) {
await this.hwState.update(() => enabled);
}
@@ -270,4 +286,12 @@ export class DesktopSettingsService {
async setMinimizeOnCopy(value: boolean, userId: UserId) {
await this.stateProvider.getUser(userId, MINIMIZE_ON_COPY).update(() => value);
}
+
+ /**
+ * Sets the modal mode of the application. Setting this changes the windows-size and other properties.
+ * @param value `true` if the application is in modal mode, `false` if it is not.
+ */
+ async setInModalMode(value: boolean) {
+ await this.inModalModeState.update(() => value);
+ }
}