mirror of
https://github.com/bitwarden/browser.git
synced 2025-03-11 13:30:39 +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:
parent
3b9be21fd7
commit
7e6f2fa798
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -119,6 +119,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
|
||||
apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev
|
||||
# DuckDuckGo integration
|
||||
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev
|
||||
|
@ -57,6 +57,7 @@ import { TwoFactorComponentV1 } from "../auth/two-factor-v1.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";
|
||||
|
||||
/**
|
||||
@ -177,6 +178,10 @@ const routes: Routes = [
|
||||
component: RemovePasswordComponent,
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: "passkeys",
|
||||
component: Fido2PlaceholderComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -284,6 +284,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();
|
||||
|
@ -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";
|
||||
@ -9,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
import { cleanUserAgent, isDev } from "../utils";
|
||||
|
||||
import { WindowMain } from "./window.main";
|
||||
|
||||
@ -49,6 +51,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"),
|
||||
@ -190,7 +197,7 @@ export class TrayMain {
|
||||
this.hideDock();
|
||||
}
|
||||
} else {
|
||||
this.windowMain.win.show();
|
||||
this.windowMain.show();
|
||||
if (this.isDarwin()) {
|
||||
this.showDock();
|
||||
}
|
||||
@ -203,4 +210,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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
@ -14,6 +14,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";
|
||||
|
||||
@ -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) => {
|
||||
if (this.win == null) {
|
||||
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.defaultWidth,
|
||||
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.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
|
||||
});
|
||||
@ -225,21 +263,41 @@ export class WindowMain {
|
||||
}
|
||||
|
||||
// Show it later since it might need to be maximized.
|
||||
this.win.show();
|
||||
// use once event to avoid flash on unstyled content.
|
||||
this.win.once("ready-to-show", () => {
|
||||
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),
|
||||
},
|
||||
);
|
||||
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()) {
|
||||
@ -336,6 +394,12 @@ export class WindowMain {
|
||||
return;
|
||||
}
|
||||
|
||||
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
|
||||
|
||||
if (inModalMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
52
apps/desktop/src/platform/popup-modal-styles.ts
Normal file
52
apps/desktop/src/platform/popup-modal-styles.ts
Normal 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();
|
||||
// }
|
||||
}
|
@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
|
||||
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>(
|
||||
DESKTOP_SETTINGS_DISK,
|
||||
"preventScreenshots",
|
||||
@ -170,6 +174,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) =>
|
||||
@ -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) {
|
||||
await this.hwState.update(() => enabled);
|
||||
}
|
||||
@ -286,6 +302,14 @@ export class DesktopSettingsService {
|
||||
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.
|
||||
* @param value `true` if the screenshot protection is enabled, `false` if it is not.
|
||||
|
Loading…
Reference in New Issue
Block a user