1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-03-12 13:39:14 +01:00

Enable Basic Desktop Modal Support (#11484)

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
This commit is contained in:
Anders Åberg 2025-03-11 09:03:28 +01:00 committed by GitHub
parent 3b9be21fd7
commit 7e6f2fa798
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 248 additions and 18 deletions

1
.github/CODEOWNERS vendored
View File

@ -119,6 +119,7 @@ apps/browser/src/autofill @bitwarden/team-autofill-dev
apps/desktop/src/autofill @bitwarden/team-autofill-dev apps/desktop/src/autofill @bitwarden/team-autofill-dev
libs/common/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/macos/autofill-extension @bitwarden/team-autofill-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev
apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev
# DuckDuckGo integration # DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev

View File

@ -57,6 +57,7 @@ import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VaultComponent } from "../vault/app/vault/vault.component"; import { VaultComponent } from "../vault/app/vault/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
import { SendComponent } from "./tools/send/send.component"; import { SendComponent } from "./tools/send/send.component";
/** /**
@ -177,6 +178,10 @@ const routes: Routes = [
component: RemovePasswordComponent, component: RemovePasswordComponent,
canActivate: [authGuard], canActivate: [authGuard],
}, },
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{ {
path: "", path: "",
component: AnonLayoutWrapperComponent, component: AnonLayoutWrapperComponent,

View File

@ -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: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<br />
<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 {
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly router: Router,
) {}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
}
}

View File

@ -284,6 +284,8 @@ export class Main {
this.migrationRunner.run().then( this.migrationRunner.run().then(
async () => { async () => {
await this.toggleHardwareAcceleration(); await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetInModalMode();
await this.windowMain.init(); await this.windowMain.init();
await this.i18nService.init(); await this.i18nService.init();
await this.messagingMain.init(); await this.messagingMain.init();

View File

@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import * as path from "path"; import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron"; import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@ -9,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management"; import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev } from "../utils";
import { WindowMain } from "./window.main"; import { WindowMain } from "./window.main";
@ -49,6 +51,11 @@ export class TrayMain {
label: this.i18nService.t("showHide"), label: this.i18nService.t("showHide"),
click: () => this.toggleWindow(), click: () => this.toggleWindow(),
}, },
{
visible: isDev(),
label: "Fake Popup",
click: () => this.fakePopup(),
},
{ type: "separator" }, { type: "separator" },
{ {
label: this.i18nService.t("exit"), label: this.i18nService.t("exit"),
@ -190,7 +197,7 @@ export class TrayMain {
this.hideDock(); this.hideDock();
} }
} else { } else {
this.windowMain.win.show(); this.windowMain.show();
if (this.isDarwin()) { if (this.isDarwin()) {
this.showDock(); this.showDock();
} }
@ -203,4 +210,38 @@ export class TrayMain {
this.windowMain.win.close(); 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();
});
}
} }

View File

@ -5,7 +5,7 @@ import * as path from "path";
import * as url from "url"; import * as url from "url";
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@ -14,6 +14,7 @@ import { processisolations } from "@bitwarden/desktop-napi";
import { BiometricStateService } from "@bitwarden/key-management"; import { BiometricStateService } from "@bitwarden/key-management";
import { WindowState } from "../platform/models/domain/window-state"; 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 { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils"; import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils";
@ -77,6 +78,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();
this.desktopSettingsService.preventScreenshots$.subscribe((prevent) => { this.desktopSettingsService.preventScreenshots$.subscribe((prevent) => {
if (this.win == null) { if (this.win == null) {
return; return;
@ -182,7 +201,20 @@ export class WindowMain {
}); });
} }
async createWindow(): Promise<void> { /// 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<void> {
this.windowStates[mainWindowSizeKey] = await this.getWindowState( this.windowStates[mainWindowSizeKey] = await this.getWindowState(
this.defaultWidth, this.defaultWidth,
this.defaultHeight, this.defaultHeight,
@ -216,6 +248,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.on("dom-ready", () => {
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0; this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
}); });
@ -225,12 +263,15 @@ export class WindowMain {
} }
// Show it later since it might need to be maximized. // Show it later since it might need to be maximized.
// use once event to avoid flash on unstyled content.
this.win.once("ready-to-show", () => {
this.win.show(); this.win.show();
});
if (template === "full-app") {
// and load the index.html of the 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. // 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 void this.win.loadURL(
this.win.loadURL(
url.format({ url.format({
protocol: "file:", protocol: "file:",
pathname: path.join(__dirname, "/index.html"), pathname: path.join(__dirname, "/index.html"),
@ -240,6 +281,23 @@ export class WindowMain {
userAgent: cleanUserAgent(this.win.webContents.userAgent), 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. // Open the DevTools.
if (isDev()) { if (isDev()) {
@ -336,6 +394,12 @@ export class WindowMain {
return; return;
} }
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
if (inModalMode) {
return;
}
try { try {
const bounds = win.getBounds(); const bounds = win.getBounds();
@ -346,9 +410,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; this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds;
// Maybe store these as well?
// win.isFocused();
// win.isVisible();
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) { if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
this.windowStates[configKey].x = bounds.x; this.windowStates[configKey].x = bounds.x;
this.windowStates[configKey].y = bounds.y; this.windowStates[configKey].y = bounds.y;

View File

@ -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();
// }
}

View File

@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear clearOn: [], // User setting, no need to clear
}); });
const IN_MODAL_MODE = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "inModalMode", {
deserializer: (b) => b,
});
const PREVENT_SCREENSHOTS = new KeyDefinition<boolean>( const PREVENT_SCREENSHOTS = new KeyDefinition<boolean>(
DESKTOP_SETTINGS_DISK, DESKTOP_SETTINGS_DISK,
"preventScreenshots", "preventScreenshots",
@ -170,6 +174,10 @@ export class DesktopSettingsService {
*/ */
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean)); 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) { constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe( this.window$ = this.windowState.state$.pipe(
map((window) => map((window) =>
@ -178,6 +186,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) { async setHardwareAcceleration(enabled: boolean) {
await this.hwState.update(() => enabled); await this.hwState.update(() => enabled);
} }
@ -286,6 +302,14 @@ export class DesktopSettingsService {
await this.stateProvider.getUser(userId, MINIMIZE_ON_COPY).update(() => value); 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);
}
/** /**
* Sets the setting for whether or not the screenshot protection is enabled. * Sets the setting for whether or not the screenshot protection is enabled.
* @param value `true` if the screenshot protection is enabled, `false` if it is not. * @param value `true` if the screenshot protection is enabled, `false` if it is not.