1
0
mirror of https://github.com/bitwarden/jslib synced 2025-12-06 00:03:29 +00:00

Split jslib into multiple modules (#363)

* Split jslib into multiple modules
This commit is contained in:
Oscar Hinton
2021-06-03 18:58:57 +02:00
committed by GitHub
parent b1d9b84eae
commit 1016bbfb9e
509 changed files with 8838 additions and 1887 deletions

3378
electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
electron/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@bitwarden/jslib-electron",
"version": "0.0.0",
"description": "Common code used across Bitwarden JavaScript projects.",
"keywords": [
"bitwarden"
],
"author": "Bitwarden Inc.",
"homepage": "https://bitwarden.com",
"repository": {
"type": "git",
"url": "https://github.com/bitwarden/jslib"
},
"license": "GPL-3.0",
"scripts": {
"clean": "rimraf dist/**/*",
"build": "npm run clean && tsc",
"build:watch": "npm run clean && tsc -watch",
"lint": "tslint 'src/**/*.ts' 'spec/**/*.ts'",
"lint:fix": "tslint 'src/**/*.ts' 'spec/**/*.ts' --fix"
},
"devDependencies": {
"@types/node": "^14.17.1",
"rimraf": "^3.0.2",
"typescript": "4.1.5"
},
"dependencies": {
"@bitwarden/jslib-common": "file:../common",
"@nodert-win10-rs4/windows.security.credentials.ui": "^0.4.4",
"electron": "11.4.5",
"electron-log": "4.3.5",
"electron-store": "8.0.0",
"electron-updater": "4.3.9",
"forcefocus": "^1.1.0",
"keytar": "7.6.0"
}
}

229
electron/src/baseMenu.ts Normal file
View File

@@ -0,0 +1,229 @@
import {
app,
clipboard,
dialog,
Menu,
MenuItemConstructorOptions,
} from 'electron';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { WindowMain } from './window.main';
export class BaseMenu {
protected editMenuItemOptions: MenuItemConstructorOptions;
protected viewSubMenuItemOptions: MenuItemConstructorOptions[];
protected windowMenuItemOptions: MenuItemConstructorOptions;
protected macAppMenuItemOptions: MenuItemConstructorOptions[];
protected macWindowSubmenuOptions: MenuItemConstructorOptions[];
constructor(protected i18nService: I18nService, protected windowMain: WindowMain) { }
protected initProperties() {
this.editMenuItemOptions = {
label: this.i18nService.t('edit'),
submenu: [
{
label: this.i18nService.t('undo'),
role: 'undo',
},
{
label: this.i18nService.t('redo'),
role: 'redo',
},
{ type: 'separator' },
{
label: this.i18nService.t('cut'),
role: 'cut',
},
{
label: this.i18nService.t('copy'),
role: 'copy',
},
{
label: this.i18nService.t('paste'),
role: 'paste',
},
{ type: 'separator' },
{
label: this.i18nService.t('selectAll'),
role: 'selectAll',
},
],
};
this.viewSubMenuItemOptions = [
{
label: this.i18nService.t('zoomIn'),
role: 'zoomIn',
accelerator: 'CmdOrCtrl+=',
},
{
label: this.i18nService.t('zoomOut'),
role: 'zoomOut',
accelerator: 'CmdOrCtrl+-',
},
{
label: this.i18nService.t('resetZoom'),
role: 'resetZoom',
accelerator: 'CmdOrCtrl+0',
},
{ type: 'separator' },
{
label: this.i18nService.t('toggleFullScreen'),
role: 'togglefullscreen',
},
{ type: 'separator' },
{
label: this.i18nService.t('reload'),
role: 'forceReload',
},
{
label: this.i18nService.t('toggleDevTools'),
role: 'toggleDevTools',
accelerator: 'F12',
},
];
this.windowMenuItemOptions = {
label: this.i18nService.t('window'),
role: 'window',
submenu: [
{
label: this.i18nService.t('minimize'),
role: 'minimize',
},
{
label: this.i18nService.t('close'),
role: 'close',
},
],
};
if (process.platform === 'darwin') {
this.macAppMenuItemOptions = [
{
label: this.i18nService.t('services'),
role: 'services', submenu: [],
},
{ type: 'separator' },
{
label: this.i18nService.t('hideBitwarden'),
role: 'hide',
},
{
label: this.i18nService.t('hideOthers'),
role: 'hideOthers',
},
{
label: this.i18nService.t('showAll'),
role: 'unhide',
},
{ type: 'separator' },
{
label: this.i18nService.t('quitBitwarden'),
role: 'quit',
},
];
this.macWindowSubmenuOptions = [
{
label: this.i18nService.t('minimize'),
role: 'minimize',
},
{
label: this.i18nService.t('zoom'),
role: 'zoom',
},
{ type: 'separator' },
{
label: this.i18nService.t('bringAllToFront'),
role: 'front',
},
{
label: this.i18nService.t('close'),
role: 'close',
},
];
}
}
protected initContextMenu() {
if (this.windowMain.win == null) {
return;
}
const selectionMenu = Menu.buildFromTemplate([
{
label: this.i18nService.t('copy'),
role: 'copy',
},
{ type: 'separator' },
{
label: this.i18nService.t('selectAll'),
role: 'selectAll',
},
]);
const inputMenu = Menu.buildFromTemplate([
{
label: this.i18nService.t('undo'),
role: 'undo',
},
{
label: this.i18nService.t('redo'),
role: 'redo',
},
{ type: 'separator' },
{
label: this.i18nService.t('cut'),
role: 'cut',
enabled: false,
},
{
label: this.i18nService.t('copy'),
role: 'copy',
enabled: false,
},
{
label: this.i18nService.t('paste'),
role: 'paste',
},
{ type: 'separator' },
{
label: this.i18nService.t('selectAll'),
role: 'selectAll',
},
]);
const inputSelectionMenu = Menu.buildFromTemplate([
{
label: this.i18nService.t('cut'),
role: 'cut',
},
{
label: this.i18nService.t('copy'),
role: 'copy',
},
{
label: this.i18nService.t('paste'),
role: 'paste',
},
{ type: 'separator' },
{
label: this.i18nService.t('selectAll'),
role: 'selectAll',
},
]);
this.windowMain.win.webContents.on('context-menu', (e, props) => {
const selected = props.selectionText && props.selectionText.trim() !== '';
if (props.isEditable && selected) {
inputSelectionMenu.popup({ window: this.windowMain.win });
} else if (props.isEditable) {
inputMenu.popup({ window: this.windowMain.win });
} else if (selected) {
selectionMenu.popup({ window: this.windowMain.win });
}
});
}
}

View File

@@ -0,0 +1,35 @@
import { I18nService, StorageService } from 'jslib-common/abstractions';
import { ipcMain, systemPreferences } from 'electron';
import { BiometricMain } from 'jslib-common/abstractions/biometric.main';
import { ConstantsService } from 'jslib-common/services/constants.service';
import { ElectronConstants } from './electronConstants';
export default class BiometricDarwinMain implements BiometricMain {
isError: boolean = false;
constructor(private storageService: StorageService, private i18nservice: I18nService) {}
async init() {
this.storageService.save(ElectronConstants.enableBiometric, await this.supportsBiometric());
this.storageService.save(ConstantsService.biometricText, 'unlockWithTouchId');
this.storageService.save(ElectronConstants.noAutoPromptBiometricsText, 'noAutoPromptTouchId');
ipcMain.on('biometric', async (event: any, message: any) => {
event.returnValue = await this.requestCreate();
});
}
supportsBiometric(): Promise<boolean> {
return Promise.resolve(systemPreferences.canPromptTouchID());
}
async requestCreate(): Promise<boolean> {
try {
await systemPreferences.promptTouchID(this.i18nservice.t('touchIdConsentMessage'));
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,132 @@
import { ipcMain } from 'electron';
import forceFocus from 'forcefocus';
import { ElectronConstants } from './electronConstants';
import { WindowMain } from './window.main';
import { BiometricMain } from 'jslib-common/abstractions/biometric.main';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { StorageService } from 'jslib-common/abstractions/storage.service';
import { ConstantsService } from 'jslib-common/services/constants.service';
export default class BiometricWindowsMain implements BiometricMain {
isError: boolean = false;
private windowsSecurityCredentialsUiModule: any;
constructor(private storageService: StorageService, private i18nservice: I18nService, private windowMain: WindowMain) { }
async init() {
this.windowsSecurityCredentialsUiModule = this.getWindowsSecurityCredentialsUiModule();
let supportsBiometric = false;
try {
supportsBiometric = await this.supportsBiometric();
} catch {
// store error state so we can let the user know on the settings page
this.isError = true;
}
this.storageService.save(ElectronConstants.enableBiometric, supportsBiometric);
this.storageService.save(ConstantsService.biometricText, 'unlockWithWindowsHello');
this.storageService.save(ElectronConstants.noAutoPromptBiometricsText, 'noAutoPromptWindowsHello');
ipcMain.on('biometric', async (event: any, message: any) => {
event.returnValue = await this.requestCreate();
});
}
async supportsBiometric(): Promise<boolean> {
const availability = await this.checkAvailabilityAsync();
return this.getAllowedAvailabilities().includes(availability);
}
async requestCreate(): Promise<boolean> {
const module = this.getWindowsSecurityCredentialsUiModule();
if (module == null) {
return false;
}
const verification = await this.requestVerificationAsync(this.i18nservice.t('windowsHelloConsentMessage'));
return verification === module.UserConsentVerificationResult.verified;
}
getWindowsSecurityCredentialsUiModule(): any {
try {
if (this.windowsSecurityCredentialsUiModule == null && this.getWindowsMajorVersion() >= 10) {
this.windowsSecurityCredentialsUiModule = require('@nodert-win10-rs4/windows.security.credentials.ui');
}
return this.windowsSecurityCredentialsUiModule;
} catch {
this.isError = true;
}
return null;
}
async checkAvailabilityAsync(): Promise<any> {
const module = this.getWindowsSecurityCredentialsUiModule();
if (module != null) {
return new Promise((resolve, reject) => {
try {
module.UserConsentVerifier.checkAvailabilityAsync((error: Error, result: any) => {
if (error) {
return resolve(null);
}
return resolve(result);
});
} catch {
this.isError = true;
return resolve(null);
}
});
}
return Promise.resolve(null);
}
async requestVerificationAsync(message: string): Promise<any> {
const module = this.getWindowsSecurityCredentialsUiModule();
if (module != null) {
return new Promise((resolve, reject) => {
try {
module.UserConsentVerifier.requestVerificationAsync(message, (error: Error, result: any) => {
if (error) {
return resolve(null);
}
return resolve(result);
});
forceFocus.focusWindow(this.windowMain.win);
} catch (error) {
this.isError = true;
return reject(error);
}
});
}
return Promise.resolve(null);
}
getAllowedAvailabilities(): any[] {
try {
const module = this.getWindowsSecurityCredentialsUiModule();
if (module != null) {
return [
module.UserConsentVerifierAvailability.available,
module.UserConsentVerifierAvailability.deviceBusy,
];
}
} catch { /*Ignore error*/ }
return [];
}
getWindowsMajorVersion(): number {
if (process.platform !== 'win32') {
return -1;
}
try {
const version = require('os').release();
return Number.parseInt(version.split('.')[0], 10);
}
catch { }
return -1;
}
}

View File

@@ -0,0 +1,15 @@
export class ElectronConstants {
static readonly enableMinimizeToTrayKey: string = 'enableMinimizeToTray';
static readonly enableCloseToTrayKey: string = 'enableCloseToTray';
static readonly enableTrayKey: string = 'enableTray';
static readonly enableStartToTrayKey: string = 'enableStartToTrayKey';
static readonly enableAlwaysOnTopKey: string = 'enableAlwaysOnTopKey';
static readonly minimizeOnCopyToClipboardKey: string = 'minimizeOnCopyToClipboardKey';
static readonly enableBiometric: string = 'enabledBiometric';
static readonly enableBrowserIntegration: string = 'enableBrowserIntegration';
static readonly enableBrowserIntegrationFingerprint: string = 'enableBrowserIntegrationFingerprint';
static readonly alwaysShowDock: string = 'alwaysShowDock';
static readonly openAtLogin: string = 'openAtLogin';
static readonly noAutoPromptBiometrics: string = 'noAutoPromptBiometrics';
static readonly noAutoPromptBiometricsText: string = 'noAutoPromptBiometricsText';
}

1
electron/src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'forcefocus';

View File

@@ -0,0 +1,32 @@
import { ipcMain } from 'electron';
import {
deletePassword,
getPassword,
setPassword,
} from 'keytar';
export class KeytarStorageListener {
constructor(private serviceName: string) { }
init() {
ipcMain.on('keytar', async (event: any, message: any) => {
try {
let val: string = null;
if (message.action && message.key) {
if (message.action === 'getPassword') {
val = await getPassword(this.serviceName, message.key);
} else if (message.action === 'setPassword' && message.value) {
await setPassword(this.serviceName, message.key, message.value);
} else if (message.action === 'deletePassword') {
await deletePassword(this.serviceName, message.key);
}
}
event.returnValue = val;
} catch {
event.returnValue = null;
}
});
}
}

View File

@@ -0,0 +1,46 @@
import log from 'electron-log';
import * as path from 'path';
import { isDev } from '../utils';
import { LogLevelType } from 'jslib-common/enums/logLevelType';
import { ConsoleLogService as BaseLogService } from 'jslib-common/services/consoleLog.service';
export class ElectronLogService extends BaseLogService {
constructor(protected filter: (level: LogLevelType) => boolean = null, logDir: string = null) {
super(isDev(), filter);
if (log.transports == null) {
return;
}
log.transports.file.level = 'info';
if (logDir != null) {
log.transports.file.file = path.join(logDir, 'app.log');
}
}
write(level: LogLevelType, message: string) {
if (this.filter != null && this.filter(level)) {
return;
}
switch (level) {
case LogLevelType.Debug:
log.debug(message);
break;
case LogLevelType.Info:
log.info(message);
break;
case LogLevelType.Warning:
log.warn(message);
break;
case LogLevelType.Error:
log.error(message);
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,56 @@
import { app, dialog, ipcMain, Menu, MenuItem, nativeTheme } from 'electron';
import { promises as fs } from 'fs';
import { MessagingService } from 'jslib-common/abstractions/messaging.service';
import { RendererMenuItem } from '../utils';
import { WindowMain } from '../window.main';
export class ElectronMainMessagingService implements MessagingService {
constructor(private windowMain: WindowMain, private onMessage: (message: any) => void) {
ipcMain.handle('appVersion', () => {
return app.getVersion();
});
ipcMain.handle('systemTheme', () => {
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
});
ipcMain.handle('showMessageBox', (event, options) => {
return dialog.showMessageBox(options);
});
ipcMain.handle('openContextMenu', (event, options: {menu: RendererMenuItem[]}) => {
return new Promise(resolve => {
const menu = new Menu();
options.menu.forEach((m, index) => {
menu.append(new MenuItem({
label: m.label,
type: m.type,
click: () => {
resolve(index);
},
}));
});
menu.popup({ window: windowMain.win, callback: () => {
resolve(-1);
}});
});
});
ipcMain.handle('windowVisible', () => {
return windowMain.win?.isVisible();
});
nativeTheme.on('updated', () => {
windowMain.win?.webContents.send('systemThemeUpdated', nativeTheme.shouldUseDarkColors ? 'dark' : 'light');
});
}
send(subscriber: string, arg: any = {}) {
const message = Object.assign({}, { command: subscriber }, arg);
this.onMessage(message);
if (this.windowMain.win != null) {
this.windowMain.win.webContents.send('messagingService', message);
}
}
}

View File

@@ -0,0 +1,211 @@
import {
clipboard,
ipcRenderer,
shell,
} from 'electron';
import {
isDev,
isMacAppStore,
} from '../utils';
import { DeviceType } from 'jslib-common/enums/deviceType';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { MessagingService } from 'jslib-common/abstractions/messaging.service';
import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service';
import { StorageService } from 'jslib-common/abstractions/storage.service';
import { ElectronConstants } from '../electronConstants';
export class ElectronPlatformUtilsService implements PlatformUtilsService {
identityClientId: string;
private deviceCache: DeviceType = null;
constructor(protected i18nService: I18nService, private messagingService: MessagingService,
private isDesktopApp: boolean, private storageService: StorageService) {
this.identityClientId = isDesktopApp ? 'desktop' : 'connector';
}
getDevice(): DeviceType {
if (!this.deviceCache) {
switch (process.platform) {
case 'win32':
this.deviceCache = DeviceType.WindowsDesktop;
break;
case 'darwin':
this.deviceCache = DeviceType.MacOsDesktop;
break;
case 'linux':
default:
this.deviceCache = DeviceType.LinuxDesktop;
break;
}
}
return this.deviceCache;
}
getDeviceString(): string {
const device = DeviceType[this.getDevice()].toLowerCase();
return device.replace('desktop', '');
}
isFirefox(): boolean {
return false;
}
isChrome(): boolean {
return true;
}
isEdge(): boolean {
return false;
}
isOpera(): boolean {
return false;
}
isVivaldi(): boolean {
return false;
}
isSafari(): boolean {
return false;
}
isIE(): boolean {
return false;
}
isMacAppStore(): boolean {
return isMacAppStore();
}
isViewOpen(): Promise<boolean> {
return Promise.resolve(false);
}
lockTimeout(): number {
return null;
}
launchUri(uri: string, options?: any): void {
shell.openExternal(uri);
}
saveFile(win: Window, blobData: any, blobOptions: any, fileName: string): void {
const blob = new Blob([blobData], blobOptions);
const a = win.document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
win.document.body.appendChild(a);
a.click();
win.document.body.removeChild(a);
}
getApplicationVersion(): Promise<string> {
return ipcRenderer.invoke('appVersion');
}
// Temporarily restricted to only Windows until https://github.com/electron/electron/pull/28349
// has been merged and an updated electron build is available.
supportsWebAuthn(win: Window): boolean {
return process.platform === 'win32';
}
supportsDuo(): boolean {
return true;
}
showToast(type: 'error' | 'success' | 'warning' | 'info', title: string, text: string | string[],
options?: any): void {
this.messagingService.send('showToast', {
text: text,
title: title,
type: type,
options: options,
});
}
async showDialog(text: string, title?: string, confirmText?: string, cancelText?: string, type?: string):
Promise<boolean> {
const buttons = [confirmText == null ? this.i18nService.t('ok') : confirmText];
if (cancelText != null) {
buttons.push(cancelText);
}
const result = await ipcRenderer.invoke('showMessageBox', {
type: type,
title: title,
message: title,
detail: text,
buttons: buttons,
cancelId: buttons.length === 2 ? 1 : null,
defaultId: 0,
noLink: true,
});
return Promise.resolve(result.response === 0);
}
async showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>):
Promise<boolean> {
throw new Error('Not implemented.');
}
isDev(): boolean {
return isDev();
}
isSelfHost(): boolean {
return false;
}
copyToClipboard(text: string, options?: any): void {
const type = options ? options.type : null;
const clearing = options ? !!options.clearing : false;
const clearMs: number = options && options.clearMs ? options.clearMs : null;
clipboard.writeText(text, type);
if (!clearing) {
this.messagingService.send('copiedToClipboard', {
clipboardValue: text,
clearMs: clearMs,
type: type,
clearing: clearing,
});
}
}
readFromClipboard(options?: any): Promise<string> {
const type = options ? options.type : null;
return Promise.resolve(clipboard.readText(type));
}
supportsBiometric(): Promise<boolean> {
return this.storageService.get(ElectronConstants.enableBiometric);
}
authenticateBiometric(): Promise<boolean> {
return new Promise(resolve => {
const val = ipcRenderer.sendSync('biometric', {
action: 'authenticate',
});
resolve(val);
});
}
getDefaultSystemTheme() {
return ipcRenderer.invoke('systemTheme');
}
onDefaultSystemThemeChange(callback: ((theme: 'light' | 'dark') => unknown)) {
ipcRenderer.on('systemThemeUpdated', (event, theme: 'light' | 'dark') => callback(theme));
}
supportsSecureStorage(): boolean {
return true;
}
}

View File

@@ -0,0 +1,26 @@
import { ipcRenderer } from 'electron';
import { BroadcasterService } from 'jslib-common/abstractions/broadcaster.service';
import { MessagingService } from 'jslib-common/abstractions/messaging.service';
export class ElectronRendererMessagingService implements MessagingService {
constructor(private broadcasterService: BroadcasterService) {
ipcRenderer.on('messagingService', async (event: any, message: any) => {
if (message.command) {
this.sendMessage(message.command, message, false);
}
});
}
send(subscriber: string, arg: any = {}) {
this.sendMessage(subscriber, arg, true);
}
private sendMessage(subscriber: string, arg: any = {}, toMain: boolean) {
const message = Object.assign({}, { command: subscriber }, arg);
this.broadcasterService.send(message);
if (toMain) {
ipcRenderer.send('messagingService', message);
}
}
}

View File

@@ -0,0 +1,30 @@
import { ipcRenderer } from 'electron';
import { StorageService } from 'jslib-common/abstractions/storage.service';
export class ElectronRendererSecureStorageService implements StorageService {
async get<T>(key: string): Promise<T> {
const val = ipcRenderer.sendSync('keytar', {
action: 'getPassword',
key: key,
});
return Promise.resolve(val != null ? JSON.parse(val) as T : null);
}
async save(key: string, obj: any): Promise<any> {
ipcRenderer.sendSync('keytar', {
action: 'setPassword',
key: key,
value: JSON.stringify(obj),
});
return Promise.resolve();
}
async remove(key: string): Promise<any> {
ipcRenderer.sendSync('keytar', {
action: 'deletePassword',
key: key,
});
return Promise.resolve();
}
}

View File

@@ -0,0 +1,27 @@
import { ipcRenderer } from 'electron';
import { StorageService } from 'jslib-common/abstractions/storage.service';
export class ElectronRendererStorageService implements StorageService {
get<T>(key: string): Promise<T> {
return ipcRenderer.invoke('storageService', {
action: 'get',
key: key,
});
}
save(key: string, obj: any): Promise<any> {
return ipcRenderer.invoke('storageService', {
action: 'save',
key: key,
obj: obj,
});
}
remove(key: string): Promise<any> {
return ipcRenderer.invoke('storageService', {
action: 'remove',
key: key,
});
}
}

View File

@@ -0,0 +1,53 @@
import { ipcMain, ipcRenderer } from 'electron';
import * as fs from 'fs';
import { StorageService } from 'jslib-common/abstractions/storage.service';
import { NodeUtils } from 'jslib-common/misc/nodeUtils';
// tslint:disable-next-line
const Store = require('electron-store');
export class ElectronStorageService implements StorageService {
private store: any;
constructor(dir: string, defaults = {}) {
if (!fs.existsSync(dir)) {
NodeUtils.mkdirpSync(dir, '700');
}
const storeConfig: any = {
defaults: defaults,
name: 'data',
};
this.store = new Store(storeConfig);
ipcMain.handle('storageService', (event, options) => {
switch (options.action) {
case 'get':
return this.get(options.key);
case 'save':
return this.save(options.key, options.obj);
case 'remove':
return this.remove(options.key);
}
});
}
get<T>(key: string): Promise<T> {
const val = this.store.get(key) as T;
return Promise.resolve(val != null ? val : null);
}
save(key: string, obj: any): Promise<any> {
if (obj instanceof Set) {
obj = Array.from(obj);
}
this.store.set(key, obj);
return Promise.resolve();
}
remove(key: string): Promise<any> {
this.store.delete(key);
return Promise.resolve();
}
}

187
electron/src/tray.main.ts Normal file
View File

@@ -0,0 +1,187 @@
import {
app,
BrowserWindow,
Menu,
MenuItem,
MenuItemConstructorOptions,
nativeImage,
Tray,
} from 'electron';
import * as path from 'path';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { StorageService } from 'jslib-common/abstractions/storage.service';
import { ElectronConstants } from './electronConstants';
import { WindowMain } from './window.main';
export class TrayMain {
contextMenu: Menu;
private appName: string;
private tray: Tray;
private icon: string | Electron.NativeImage;
private pressedIcon: Electron.NativeImage;
constructor(private windowMain: WindowMain, private i18nService: I18nService,
private storageService: StorageService) {
if (process.platform === 'win32') {
this.icon = path.join(__dirname, '/images/icon.ico');
} else if (process.platform === 'darwin') {
const nImage = nativeImage.createFromPath(path.join(__dirname, '/images/icon-template.png'));
nImage.setTemplateImage(true);
this.icon = nImage;
this.pressedIcon = nativeImage.createFromPath(path.join(__dirname, '/images/icon-highlight.png'));
} else {
this.icon = path.join(__dirname, '/images/icon.png');
}
}
async init(appName: string, additionalMenuItems: MenuItemConstructorOptions[] = null) {
this.appName = appName;
const menuItemOptions: MenuItemConstructorOptions[] = [{
label: this.i18nService.t('showHide'),
click: () => this.toggleWindow(),
},
{ type: 'separator' },
{
label: this.i18nService.t('exit'),
click: () => this.closeWindow(),
}];
if (additionalMenuItems != null) {
menuItemOptions.splice(1, 0, ...additionalMenuItems);
}
this.contextMenu = Menu.buildFromTemplate(menuItemOptions);
if (await this.storageService.get<boolean>(ElectronConstants.enableTrayKey)) {
this.showTray();
}
}
setupWindowListeners(win: BrowserWindow) {
win.on('minimize', async (e: Event) => {
if (await this.storageService.get<boolean>(ElectronConstants.enableMinimizeToTrayKey)) {
e.preventDefault();
this.hideToTray();
}
});
win.on('close', async (e: Event) => {
if (await this.storageService.get<boolean>(ElectronConstants.enableCloseToTrayKey)) {
if (!this.windowMain.isQuitting) {
e.preventDefault();
this.hideToTray();
}
}
});
win.on('show', async (e: Event) => {
const enableTray = await this.storageService.get<boolean>(ElectronConstants.enableTrayKey);
if (!enableTray) {
setTimeout(() => this.removeTray(false), 100);
}
});
}
removeTray(showWindow = true) {
// Due to https://github.com/electron/electron/issues/17622
// we cannot destroy the tray icon on linux.
if (this.tray != null && process.platform !== 'linux') {
this.tray.destroy();
this.tray = null;
}
if (showWindow && this.windowMain.win != null && !this.windowMain.win.isVisible()) {
this.windowMain.win.show();
}
}
async hideToTray() {
this.showTray();
if (this.windowMain.win != null) {
this.windowMain.win.hide();
}
if (this.isDarwin() && !await this.storageService.get<boolean>(ElectronConstants.alwaysShowDock)) {
this.hideDock();
}
}
restoreFromTray() {
if (this.windowMain.win == null || !this.windowMain.win.isVisible()) {
this.toggleWindow();
}
}
showTray() {
if (this.tray != null) {
return;
}
this.tray = new Tray(this.icon);
this.tray.setToolTip(this.appName);
this.tray.on('click', () => this.toggleWindow());
this.tray.on('right-click', () => this.tray.popUpContextMenu(this.contextMenu));
if (this.pressedIcon != null) {
this.tray.setPressedImage(this.pressedIcon);
}
if (this.contextMenu != null && !this.isDarwin()) {
this.tray.setContextMenu(this.contextMenu);
}
}
updateContextMenu() {
if (this.contextMenu != null && this.isLinux()) {
this.tray.setContextMenu(this.contextMenu);
}
}
private hideDock() {
app.dock.hide();
}
private showDock() {
app.dock.show();
}
private isDarwin() {
return process.platform === 'darwin';
}
private isLinux() {
return process.platform === 'linux';
}
private async toggleWindow() {
if (this.windowMain.win == null) {
if (this.isDarwin()) {
// On MacOS, closing the window via the red button destroys the BrowserWindow instance.
this.windowMain.createWindow().then(() => {
this.windowMain.win.show();
this.showDock();
});
}
return;
}
if (this.windowMain.win.isVisible()) {
this.windowMain.win.hide();
if (this.isDarwin() && !await this.storageService.get<boolean>(ElectronConstants.alwaysShowDock)) {
this.hideDock();
}
} else {
this.windowMain.win.show();
if (this.isDarwin()) {
this.showDock();
}
}
}
private closeWindow() {
this.windowMain.isQuitting = true;
if (this.windowMain.win != null) {
this.windowMain.win.close();
}
}
}

View File

@@ -0,0 +1,155 @@
import {
dialog,
Menu,
MenuItem,
shell,
} from 'electron';
import log from 'electron-log';
import { autoUpdater } from 'electron-updater';
import {
isAppImage,
isDev,
isMacAppStore,
isWindowsPortable,
isWindowsStore,
} from './utils';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { WindowMain } from './window.main';
const UpdaterCheckInitalDelay = 5 * 1000; // 5 seconds
const UpdaterCheckInterval = 12 * 60 * 60 * 1000; // 12 hours
export class UpdaterMain {
private doingUpdateCheck = false;
private doingUpdateCheckWithFeedback = false;
private canUpdate = false;
constructor(private i18nService: I18nService, private windowMain: WindowMain,
private gitHubProject: string, private onCheckingForUpdate: () => void = null,
private onReset: () => void = null, private onUpdateDownloaded: () => void = null,
private projectName: string) {
autoUpdater.logger = log;
const linuxCanUpdate = process.platform === 'linux' && isAppImage();
const windowsCanUpdate = process.platform === 'win32' && !isWindowsStore() && !isWindowsPortable();
const macCanUpdate = process.platform === 'darwin' && !isMacAppStore();
this.canUpdate = process.env.ELECTRON_NO_UPDATER !== '1' &&
(linuxCanUpdate || windowsCanUpdate || macCanUpdate);
}
async init() {
global.setTimeout(async () => await this.checkForUpdate(), UpdaterCheckInitalDelay);
global.setInterval(async () => await this.checkForUpdate(), UpdaterCheckInterval);
autoUpdater.on('checking-for-update', () => {
if (this.onCheckingForUpdate != null) {
this.onCheckingForUpdate();
}
this.doingUpdateCheck = true;
});
autoUpdater.on('update-available', async () => {
if (this.doingUpdateCheckWithFeedback) {
if (this.windowMain.win == null) {
this.reset();
return;
}
const result = await dialog.showMessageBox(this.windowMain.win, {
type: 'info',
title: this.i18nService.t(this.projectName) + ' - ' + this.i18nService.t('updateAvailable'),
message: this.i18nService.t('updateAvailable'),
detail: this.i18nService.t('updateAvailableDesc'),
buttons: [this.i18nService.t('yes'), this.i18nService.t('no')],
cancelId: 1,
defaultId: 0,
noLink: true,
});
if (result.response === 0) {
autoUpdater.downloadUpdate();
} else {
this.reset();
}
}
});
autoUpdater.on('update-not-available', () => {
if (this.doingUpdateCheckWithFeedback && this.windowMain.win != null) {
dialog.showMessageBox(this.windowMain.win, {
message: this.i18nService.t('noUpdatesAvailable'),
buttons: [this.i18nService.t('ok')],
defaultId: 0,
noLink: true,
});
}
this.reset();
});
autoUpdater.on('update-downloaded', async info => {
if (this.onUpdateDownloaded != null) {
this.onUpdateDownloaded();
}
if (this.windowMain.win == null) {
return;
}
const result = await dialog.showMessageBox(this.windowMain.win, {
type: 'info',
title: this.i18nService.t(this.projectName) + ' - ' + this.i18nService.t('restartToUpdate'),
message: this.i18nService.t('restartToUpdate'),
detail: this.i18nService.t('restartToUpdateDesc', info.version),
buttons: [this.i18nService.t('restart'), this.i18nService.t('later')],
cancelId: 1,
defaultId: 0,
noLink: true,
});
if (result.response === 0) {
autoUpdater.quitAndInstall(false, true);
}
});
autoUpdater.on('error', error => {
if (this.doingUpdateCheckWithFeedback) {
dialog.showErrorBox(this.i18nService.t('updateError'),
error == null ? this.i18nService.t('unknown') : (error.stack || error).toString());
}
this.reset();
});
}
async checkForUpdate(withFeedback: boolean = false) {
if (this.doingUpdateCheck || isDev()) {
return;
}
if (!this.canUpdate) {
if (withFeedback) {
shell.openExternal('https://github.com/bitwarden/' + this.gitHubProject + '/releases');
}
return;
}
this.doingUpdateCheckWithFeedback = withFeedback;
if (withFeedback) {
autoUpdater.autoDownload = false;
}
await autoUpdater.checkForUpdates();
}
private reset() {
if (this.onReset != null) {
this.onReset();
}
autoUpdater.autoDownload = true;
this.doingUpdateCheck = false;
}
}

48
electron/src/utils.ts Normal file
View File

@@ -0,0 +1,48 @@
import { ipcRenderer } from 'electron';
export type RendererMenuItem = {label?: string, type?: ('normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'), click?: () => any};
export function invokeMenu(menu: RendererMenuItem[]) {
const menuWithoutClick = menu.map(m => {
return { label: m.label, type: m.type };
});
ipcRenderer.invoke('openContextMenu', { menu: menuWithoutClick }).then((i: number) => {
if (i !== -1) {
menu[i].click();
}
});
}
export function isDev() {
// ref: https://github.com/sindresorhus/electron-is-dev
if ('ELECTRON_IS_DEV' in process.env) {
return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
}
return (process.defaultApp || /node_modules[\\/]electron[\\/]/.test(process.execPath));
}
export function isAppImage() {
return process.platform === 'linux' && 'APPIMAGE' in process.env;
}
export function isMacAppStore() {
return process.platform === 'darwin' && process.mas && process.mas === true;
}
export function isWindowsStore() {
const isWindows = process.platform === 'win32';
let windowsStore = process.windowsStore;
if (isWindows && !windowsStore &&
process.resourcesPath.indexOf('8bitSolutionsLLC.bitwardendesktop_') > -1) {
windowsStore = true;
}
return isWindows && windowsStore === true;
}
export function isSnapStore() {
return process.platform === 'linux' && process.env.SNAP_USER_DATA != null;
}
export function isWindowsPortable() {
return process.platform === 'win32' && process.env.PORTABLE_EXECUTABLE_DIR != null;
}

271
electron/src/window.main.ts Normal file
View File

@@ -0,0 +1,271 @@
import { app, BrowserWindow, screen } from 'electron';
import { ElectronConstants } from './electronConstants';
import * as path from 'path';
import * as url from 'url';
import { isDev, isMacAppStore, isSnapStore } from './utils';
import { StorageService } from 'jslib-common/abstractions/storage.service';
const WindowEventHandlingDelay = 100;
const Keys = {
mainWindowSize: 'mainWindowSize',
};
export class WindowMain {
win: BrowserWindow;
isQuitting: boolean = false;
private windowStateChangeTimer: NodeJS.Timer;
private windowStates: { [key: string]: any; } = {};
private enableAlwaysOnTop: boolean = false;
constructor(private storageService: StorageService, private hideTitleBar = false,
private defaultWidth = 950, private defaultHeight = 600,
private argvCallback: (argv: string[]) => void = null,
private createWindowCallback: (win: BrowserWindow) => void) { }
init(): Promise<any> {
return new Promise<void>((resolve, reject) => {
try {
if (!isMacAppStore() && !isSnapStore()) {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
} else {
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 (process.platform === 'win32' || process.platform === 'linux') {
if (this.argvCallback != null) {
this.argvCallback(argv);
}
}
});
}
}
// This method will be called when Electron is shutting
// down the application.
app.on('before-quit', () => {
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 () => {
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 (process.platform !== 'darwin' || 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[Keys.mainWindowSize] = await this.getWindowState(Keys.mainWindowSize, this.defaultWidth,
this.defaultHeight);
this.enableAlwaysOnTop = await this.storageService.get<boolean>(ElectronConstants.enableAlwaysOnTopKey);
// Create the browser window.
this.win = new BrowserWindow({
width: this.windowStates[Keys.mainWindowSize].width,
height: this.windowStates[Keys.mainWindowSize].height,
minWidth: 680,
minHeight: 500,
x: this.windowStates[Keys.mainWindowSize].x,
y: this.windowStates[Keys.mainWindowSize].y,
title: app.name,
icon: process.platform === 'linux' ? path.join(__dirname, '/images/icon.png') : undefined,
titleBarStyle: this.hideTitleBar && process.platform === 'darwin' ? 'hiddenInset' : undefined,
show: false,
backgroundColor: '#fff',
alwaysOnTop: this.enableAlwaysOnTop,
webPreferences: {
nodeIntegration: true,
webviewTag: true,
backgroundThrottling: false,
enableRemoteModule: true, // TODO: This needs to be removed prior to Electron 14.
},
});
if (this.windowStates[Keys.mainWindowSize].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.
this.win.loadURL(url.format({
protocol: 'file:',
pathname: path.join(__dirname, '/index.html'),
slashes: true,
}), {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0',
});
// Open the DevTools.
if (isDev()) {
this.win.webContents.openDevTools();
}
// Emitted when the window is closed.
this.win.on('closed', async () => {
await this.updateWindowState(Keys.mainWindowSize, 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 () => {
await this.updateWindowState(Keys.mainWindowSize, this.win);
});
this.win.on('maximize', async () => {
await this.updateWindowState(Keys.mainWindowSize, this.win);
});
this.win.on('unmaximize', async () => {
await this.updateWindowState(Keys.mainWindowSize, this.win);
});
this.win.on('resize', () => {
this.windowStateChangeHandler(Keys.mainWindowSize, this.win);
});
this.win.on('move', () => {
this.windowStateChangeHandler(Keys.mainWindowSize, this.win);
});
if (this.createWindowCallback) {
this.createWindowCallback(this.win);
}
}
async toggleAlwaysOnTop() {
this.enableAlwaysOnTop = !this.win.isAlwaysOnTop();
this.win.setAlwaysOnTop(this.enableAlwaysOnTop);
await this.storageService.save(ElectronConstants.enableAlwaysOnTopKey, 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) {
return;
}
try {
const bounds = win.getBounds();
if (this.windowStates[configKey] == null) {
this.windowStates[configKey] = await this.storageService.get<any>(configKey);
if (this.windowStates[configKey] == null) {
this.windowStates[configKey] = {};
}
}
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;
}
await this.storageService.save(configKey, this.windowStates[configKey]);
} catch (e) { }
}
private async getWindowState(configKey: string, defaultWidth: number, defaultHeight: number) {
let state = await this.storageService.get<any>(configKey);
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
let displayBounds: Electron.Rectangle = null;
if (!isValid) {
state = {
width: defaultWidth,
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;
}
}

30
electron/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"pretty": true,
"moduleResolution": "node",
"noImplicitAny": true,
"target": "ES6",
"module": "commonjs",
"lib": ["es5", "es6", "es7", "dom"],
"sourceMap": true,
"declaration": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declarationDir": "dist/types",
"outDir": "dist",
"paths": {
"jslib-common/*": [
"../common/src/*"
]
}
},
"include": [
"src",
"spec"
],
"exclude": [
"node_modules",
"dist"
]
}