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); + } }