1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00
Files
browser/apps/desktop/src/main/window.main.ts
Bernd Schoolmann 081fe83d83 PM-10393 SSH keys (#10825)
* [PM-10395] Add new item type ssh key (#10360)

* Implement ssh-key cipher type

* Fix linting

* Fix edit and view components for ssh-keys on desktop

* Fix tests

* Remove ssh key type references

* Remove add ssh key option

* Fix typo

* Add tests

* [PM-10399] Add ssh key import export for bitwarden json (#10529)

* Add ssh key import export for bitwarden json

* Remove key type from ssh key export

* [PM-10406] Add privatekey publickey and fingerprint to both add-edit and view co… (#11046)

* Add privatekey publickey and fingerprint to both add-edit and view components

* Remove wrong a11y title

* Fix testid

* [PM-10098] SSH Agent & SSH Key creation for Bitwarden Desktop (#10293)

* Add ssh agent, generator & import

* Move ssh agent code to bitwarden-russh crate

* Remove generator component

* Cleanup

* Cleanup

* Remove left over sshGenerator reference

* Cleanup

* Add documentation to sshkeyimportstatus

* Fix outdated variable name

* Update apps/desktop/src/platform/preload.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Rename renderersshagent

* Rename MainSshAgentService

* Improve clarity of 'id' variables being used

* Improve clarity of 'id' variables being used

* Update apps/desktop/src/vault/app/vault/add-edit.component.html

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Fix outdated cipher/messageid names

* Rename SSH to Ssh

* Make agent syncing more reactive

* Move constants to top of class

* Make sshkey cipher filtering clearer

* Add stricter equality check on ssh key unlock

* Fix build and messages

* Fix incorrect featureflag name

* Replace anonymous async function with switchmap pipe

* Fix build

* Update apps/desktop/desktop_native/napi/src/lib.rs

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Revert incorrectly renamed 'Ssh' usages to SSH

* Run cargo fmt

* Clean up ssh agent sock path logic

* Cleanup and split to platform specific files

* Small cleanup

* Pull out generator and importer into core

* Rename renderersshagentservice to sshagentservice

* Rename cipheruuid to cipher_id

* Drop ssh dependencies from napi crate

* Clean up windows build

* Small cleanup

* Small cleanup

* Cleanup

* Add rxjs pipeline for agent services

* [PM-12555] Pkcs8 sshkey import & general ssh key import tests (#11048)

* Add pkcs8 import and tests

* Add key type unsupported error

* Remove unsupported formats

* Remove code for unsupported formats

* Fix encrypted pkcs8 import

* Add ed25519 pkcs8 unencrypted test file

* SSH agent rxjs tweaks (#11148)

* feat: rewrite sshagent.signrequest as purely observable

* feat: fail the request when unlock times out

* chore: clean up, add some clarifying comments

* chore: remove unused dependency

* fix: result `undefined` crashing in NAPI -> Rust

* Allow concurrent SSH requests in rust

* Remove unwraps

* Cleanup and add init service init call

* Fix windows

* Fix timeout behavior on locked vault

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Fix libc dependency being duplicated

* fix SSH casing (#11840)

* Move ssh agent behind feature flag (#11841)

* Move ssh agent behind feature flag

* Add separate flag for ssh agent

* [PM-14215] fix unsupported key type error message (#11788)

* Fix error message for import of unsupported ssh keys

* Use triple equals in add-edit component for ssh keys

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
Co-authored-by: aj-bw <81774843+aj-bw@users.noreply.github.com>
2024-11-08 11:01:31 +01:00

412 lines
14 KiB
TypeScript

import { once } from "node:events";
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
import { firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { processisolations } from "@bitwarden/desktop-napi";
import { BiometricStateService } from "@bitwarden/key-management";
import { WindowState } from "../platform/models/domain/window-state";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import {
cleanUserAgent,
isDev,
isLinux,
isMac,
isMacAppStore,
isSnapStore,
isWindows,
} from "../utils";
const mainWindowSizeKey = "mainWindowSize";
const WindowEventHandlingDelay = 100;
export class WindowMain {
win: BrowserWindow;
isQuitting = false;
isClosing = false;
private windowStateChangeTimer: NodeJS.Timeout;
private windowStates: { [key: string]: WindowState } = {};
private enableAlwaysOnTop = false;
private enableRendererProcessForceCrashReload = false;
session: Electron.Session;
readonly defaultWidth = 950;
readonly defaultHeight = 600;
constructor(
private biometricStateService: BiometricStateService,
private logService: LogService,
private storageService: AbstractStorageService,
private desktopSettingsService: DesktopSettingsService,
private argvCallback: (argv: string[]) => void = null,
private createWindowCallback: (win: BrowserWindow) => void,
) {}
init(): Promise<any> {
// Perform a hard reload of the render process by crashing it. This is suboptimal but ensures that all memory gets
// cleared, as the process itself will be completely garbage collected.
ipcMain.on("reload-process", async () => {
this.logService.info("Reloading render process");
// User might have changed theme, ensure the window is updated.
this.win.setBackgroundColor(await this.getBackgroundColor());
// By default some linux distro collect core dumps on crashes which gets written to disk.
if (this.enableRendererProcessForceCrashReload) {
const crashEvent = once(this.win.webContents, "render-process-gone");
this.win.webContents.forcefullyCrashRenderer();
await crashEvent;
}
this.win.webContents.reloadIgnoringCache();
// 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.session.clearCache();
this.logService.info("Render process reloaded");
});
ipcMain.on("window-focus", () => {
if (this.win != null) {
this.win.show();
this.win.focus();
}
});
ipcMain.on("window-hide", () => {
if (this.win != null) {
this.win.hide();
}
});
return new Promise<void>((resolve, reject) => {
try {
if (!isMacAppStore() && !isSnapStore()) {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
} else {
// eslint-disable-next-line
app.on("second-instance", (event, argv, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (this.win != null) {
if (this.win.isMinimized() || !this.win.isVisible()) {
this.win.show();
}
this.win.focus();
}
if (isWindows() || isLinux()) {
if (this.argvCallback != null) {
this.argvCallback(argv);
}
}
});
}
}
// This method will be called when Electron is shutting
// down the application.
app.on("before-quit", async () => {
// Allow biometric to auto-prompt on reload
await this.biometricStateService.resetAllPromptCancelled();
this.isQuitting = true;
});
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", async () => {
if (isMac() || isWindows()) {
this.enableRendererProcessForceCrashReload = true;
} else if (isLinux() && !isDev()) {
if (await processisolations.isCoreDumpingDisabled()) {
this.logService.info("Coredumps are disabled in renderer process");
this.enableRendererProcessForceCrashReload = true;
} else {
this.logService.info("Disabling coredumps in main process");
try {
await processisolations.disableCoredumps();
} catch (e) {
this.logService.error("Failed to disable coredumps", e);
}
}
// this currently breaks the file portal, so should only be used when
// no files are needed but security requirements are super high https://github.com/flatpak/xdg-desktop-portal/issues/785
if (process.env.EXPERIMENTAL_PREVENT_DEBUGGER_MEMORY_ACCESS === "true") {
this.logService.info("Disabling memory dumps in main process");
try {
await processisolations.disableMemoryAccess();
} catch (e) {
this.logService.error("Failed to disable memory dumps", e);
}
}
}
await this.createWindow();
resolve();
if (this.argvCallback != null) {
this.argvCallback(process.argv);
}
});
// Quit when all windows are closed.
app.on("window-all-closed", () => {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (!isMac() || this.isQuitting || isMacAppStore()) {
app.quit();
}
});
app.on("activate", async () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (this.win == null) {
await this.createWindow();
} else {
// Show the window when clicking on Dock icon
this.win.show();
}
});
} catch (e) {
// Catch Error
// throw e;
reject(e);
}
});
}
async createWindow(): Promise<void> {
this.windowStates[mainWindowSizeKey] = await this.getWindowState(
this.defaultWidth,
this.defaultHeight,
);
this.enableAlwaysOnTop = await firstValueFrom(this.desktopSettingsService.alwaysOnTop$);
this.session = session.fromPartition("persist:bitwarden", { cache: false });
// Create the browser window.
this.win = new BrowserWindow({
width: this.windowStates[mainWindowSizeKey].width,
height: this.windowStates[mainWindowSizeKey].height,
minWidth: 680,
minHeight: 500,
x: this.windowStates[mainWindowSizeKey].x,
y: this.windowStates[mainWindowSizeKey].y,
title: app.name,
icon: isLinux() ? path.join(__dirname, "/images/icon.png") : undefined,
titleBarStyle: isMac() ? "hiddenInset" : undefined,
show: false,
backgroundColor: await this.getBackgroundColor(),
alwaysOnTop: this.enableAlwaysOnTop,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
spellcheck: false,
nodeIntegration: false,
backgroundThrottling: false,
contextIsolation: true,
session: this.session,
devTools: isDev(),
},
});
this.win.webContents.on("dom-ready", () => {
this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0;
});
if (this.windowStates[mainWindowSizeKey].isMaximized) {
this.win.maximize();
}
// 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),
},
);
// Open the DevTools.
if (isDev()) {
this.win.webContents.openDevTools();
}
// Emitted when the window is closed.
this.win.on("closed", async () => {
this.isClosing = false;
await this.updateWindowState(mainWindowSizeKey, this.win);
// Dereference the window object, usually you would store window
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
this.win = null;
});
this.win.on("close", async () => {
this.isClosing = true;
await this.updateWindowState(mainWindowSizeKey, this.win);
});
this.win.on("maximize", async () => {
await this.updateWindowState(mainWindowSizeKey, this.win);
});
this.win.on("unmaximize", async () => {
await this.updateWindowState(mainWindowSizeKey, this.win);
});
this.win.on("resize", () => {
this.windowStateChangeHandler(mainWindowSizeKey, this.win);
});
this.win.on("move", () => {
this.windowStateChangeHandler(mainWindowSizeKey, this.win);
});
this.win.on("focus", () => {
this.win.webContents.send("messagingService", {
command: "windowIsFocused",
windowIsFocused: true,
});
});
if (this.createWindowCallback) {
this.createWindowCallback(this.win);
}
}
// Retrieve the background color
// Resolves background color missmatch when starting the application.
async getBackgroundColor(): Promise<string> {
let theme = await this.storageService.get("global_theming_selection");
if (theme == null || theme === "system") {
theme = nativeTheme.shouldUseDarkColors ? "dark" : "light";
}
switch (theme) {
case "light":
return "#ededed";
case "dark":
return "#15181e";
case "nord":
return "#3b4252";
}
}
async toggleAlwaysOnTop() {
this.enableAlwaysOnTop = !this.win.isAlwaysOnTop();
this.win.setAlwaysOnTop(this.enableAlwaysOnTop);
await this.desktopSettingsService.setAlwaysOnTop(this.enableAlwaysOnTop);
}
private windowStateChangeHandler(configKey: string, win: BrowserWindow) {
global.clearTimeout(this.windowStateChangeTimer);
this.windowStateChangeTimer = global.setTimeout(async () => {
await this.updateWindowState(configKey, win);
}, WindowEventHandlingDelay);
}
private async updateWindowState(configKey: string, win: BrowserWindow) {
if (win == null || win.isDestroyed()) {
return;
}
try {
const bounds = win.getBounds();
if (this.windowStates[configKey] == null) {
this.windowStates[configKey] = await firstValueFrom(this.desktopSettingsService.window$);
if (this.windowStates[configKey] == null) {
this.windowStates[configKey] = <WindowState>{};
}
}
this.windowStates[configKey].isMaximized = win.isMaximized();
this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds;
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
this.windowStates[configKey].x = bounds.x;
this.windowStates[configKey].y = bounds.y;
this.windowStates[configKey].width = bounds.width;
this.windowStates[configKey].height = bounds.height;
}
if (this.isClosing) {
this.windowStates[configKey].zoomFactor = win.webContents.zoomFactor;
}
await this.desktopSettingsService.setWindow(this.windowStates[configKey]);
} catch (e) {
this.logService.error(e);
}
}
private async getWindowState(defaultWidth: number, defaultHeight: number) {
const state = await firstValueFrom(this.desktopSettingsService.window$);
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
let displayBounds: Electron.Rectangle = null;
if (!isValid) {
state.width = defaultWidth;
state.height = defaultHeight;
displayBounds = screen.getPrimaryDisplay().bounds;
} else if (this.stateHasBounds(state) && state.displayBounds) {
// Check if the display where the window was last open is still available
displayBounds = screen.getDisplayMatching(state.displayBounds).bounds;
if (
displayBounds.width !== state.displayBounds.width ||
displayBounds.height !== state.displayBounds.height ||
displayBounds.x !== state.displayBounds.x ||
displayBounds.y !== state.displayBounds.y
) {
state.x = undefined;
state.y = undefined;
displayBounds = screen.getPrimaryDisplay().bounds;
}
}
if (displayBounds != null) {
if (state.width > displayBounds.width && state.height > displayBounds.height) {
state.isMaximized = true;
}
if (state.width > displayBounds.width) {
state.width = displayBounds.width - 10;
}
if (state.height > displayBounds.height) {
state.height = displayBounds.height - 10;
}
}
return state;
}
private stateHasBounds(state: any): boolean {
return (
state != null &&
Number.isInteger(state.x) &&
Number.isInteger(state.y) &&
Number.isInteger(state.width) &&
state.width > 0 &&
Number.isInteger(state.height) &&
state.height > 0
);
}
}