import { clearSensitiveDirectories } from '@standardnotes/electron-clear-data' import { BrowserWindow, Rectangle, screen, Shell } from 'electron' import fs from 'fs' import { debounce } from 'lodash' import path from 'path' import { AppMessageType, MessageType } from '../../../test/TestIpcMessage' import { AppState } from '../../application' import { MessageToWebApp } from '../Shared/IpcMessages' import { createBackupsManager } from './Backups/BackupsManager' import { BackupsManagerInterface } from './Backups/BackupsManagerInterface' import { FilesBackupManager } from './FileBackups/FileBackupsManager' import { Keychain } from './Keychain/Keychain' import { MenuManagerInterface } from './Menus/MenuManagerInterface' import { buildContextMenu, createMenuManager } from './Menus/Menus' import { initializePackageManager } from './Packages/PackageManager' import { RemoteBridge } from './Remote/RemoteBridge' import { initializeSearchManager } from './Search/SearchManager' import { createSpellcheckerManager } from './SpellcheckerManager' import { Store, StoreKeys } from './Store' import { createTrayManager, TrayManager } from './TrayManager' import { Paths } from './Types/Paths' import { isMac, isWindows } from './Types/Platforms' import { checkForUpdate, setupUpdates } from './UpdateManager' import { handleTestMessage, send } from './Utils/Testing' import { isTesting, lowercaseDriveLetter } from './Utils/Utils' import { initializeZoomManager } from './ZoomManager' const WINDOW_DEFAULT_WIDTH = 1100 const WINDOW_DEFAULT_HEIGHT = 800 const WINDOW_MIN_WIDTH = 300 const WINDOW_MIN_HEIGHT = 400 export interface WindowState { window: Electron.BrowserWindow menuManager: MenuManagerInterface backupsManager: BackupsManagerInterface trayManager: TrayManager } function hideWindowsTaskbarPreviewThumbnail(window: BrowserWindow) { if (isWindows()) { window.setThumbnailClip({ x: 0, y: 0, width: 1, height: 1 }) } } export async function createWindowState({ shell, appState, appLocale, teardown, }: { shell: Shell appLocale: string appState: AppState teardown: () => void }): Promise { const window = await createWindow(appState.store) const services = await createWindowServices(window, appState, appLocale) require('@electron/remote/main').enable(window.webContents) ;(global as any).RemoteBridge = new RemoteBridge( window, Keychain, services.backupsManager, services.packageManager, services.searchManager, { destroySensitiveDirectories: () => { const restart = true clearSensitiveDirectories(restart) }, }, services.menuManager, services.fileBackupsManager, ) const shouldOpenUrl = (url: string) => url.startsWith('http') || url.startsWith('mailto') window.on('closed', teardown) window.on('show', () => { void checkForUpdate(appState, appState.updates, false) hideWindowsTaskbarPreviewThumbnail(window) }) window.on('focus', () => { window.webContents.send(MessageToWebApp.WindowFocused, null) }) window.on('blur', () => { window.webContents.send(MessageToWebApp.WindowBlurred, null) services.backupsManager.applicationDidBlur() }) window.once('ready-to-show', () => { window.show() }) window.on('close', (event) => { if (!appState.willQuitApp && (isMac() || services.trayManager.shouldMinimizeToTray())) { /** * On MacOS, closing a window does not quit the app. On Window and Linux, * it only does if you haven't enabled minimize to tray. */ event.preventDefault() /** * Handles Mac full screen issue where pressing close results * in a black screen. */ if (window.isFullScreen()) { window.setFullScreen(false) } window.hide() } }) window.webContents.session.setSpellCheckerDictionaryDownloadURL('https://dictionaries.standardnotes.org/9.4.4/') /** handle link clicks */ window.webContents.on('new-window', (event, url) => { if (shouldOpenUrl(url)) { void shell.openExternal(url) } event.preventDefault() }) /** * handle link clicks (this event is fired instead of 'new-window' when * target is not set to _blank, such as with window.location.assign) */ window.webContents.on('will-navigate', (event, url) => { /** Check for windowUrl equality in the case of window.reload() calls. */ if (fileUrlsAreEqual(url, appState.startUrl)) { return } if (shouldOpenUrl(url)) { void shell.openExternal(url) } event.preventDefault() }) window.webContents.on('context-menu', (_event, params) => { buildContextMenu(window.webContents, params).popup() }) return { window, ...services, } } async function createWindow(store: Store): Promise { const useSystemMenuBar = store.get(StoreKeys.UseSystemMenuBar) const position = await getPreviousWindowPosition() const window = new BrowserWindow({ ...position.bounds, minWidth: WINDOW_MIN_WIDTH, minHeight: WINDOW_MIN_HEIGHT, show: false, icon: path.join(__dirname, '/icon/Icon-512x512.png'), titleBarStyle: isMac() || useSystemMenuBar ? 'hiddenInset' : undefined, frame: isMac() ? false : useSystemMenuBar, webPreferences: { spellcheck: true, nodeIntegration: isTesting(), contextIsolation: true, preload: Paths.preloadJs, }, }) if (position.isFullScreen) { window.setFullScreen(true) } if (position.isMaximized) { window.maximize() } persistWindowPosition(window) if (isTesting()) { handleTestMessage(MessageType.SpellCheckerLanguages, () => window.webContents.session.getSpellCheckerLanguages()) handleTestMessage(MessageType.SetLocalStorageValue, async (key, value) => { await window.webContents.executeJavaScript(`localStorage.setItem("${key}", "${value}")`) window.webContents.session.flushStorageData() }) handleTestMessage(MessageType.SignOut, () => window.webContents.executeJavaScript('window.device.onSignOut(false)')) window.webContents.once('did-finish-load', () => { send(AppMessageType.WindowLoaded) }) } return window } async function createWindowServices(window: Electron.BrowserWindow, appState: AppState, appLocale: string) { const packageManager = await initializePackageManager(window.webContents) const searchManager = initializeSearchManager(window.webContents) initializeZoomManager(window, appState.store) const backupsManager = createBackupsManager(window.webContents, appState) const updateManager = setupUpdates(window, appState, backupsManager) const trayManager = createTrayManager(window, appState.store) const spellcheckerManager = createSpellcheckerManager(appState.store, window.webContents, appLocale) if (isTesting()) { handleTestMessage(MessageType.SpellCheckerManager, () => spellcheckerManager) } const menuManager = createMenuManager({ appState, window, backupsManager, trayManager, store: appState.store, spellcheckerManager, }) const fileBackupsManager = new FilesBackupManager(appState) return { backupsManager, updateManager, trayManager, spellcheckerManager, menuManager, packageManager, searchManager, fileBackupsManager, } } /** * Check file urls for equality by decoding components * In packaged app, spaces in navigation events urls can contain %20 * but not in windowUrl. */ function fileUrlsAreEqual(a: string, b: string): boolean { /** Catch exceptions in case of malformed urls. */ try { /** * Craft URL objects to eliminate production URL values that can * contain "#!/" suffixes (on Windows) */ let aPath = new URL(decodeURIComponent(a)).pathname let bPath = new URL(decodeURIComponent(b)).pathname if (isWindows()) { /** On Windows, drive letter casing is inconsistent */ aPath = lowercaseDriveLetter(aPath) bPath = lowercaseDriveLetter(bPath) } return aPath === bPath } catch (error) { return false } } interface WindowPosition { bounds: Rectangle isMaximized: boolean isFullScreen: boolean } async function getPreviousWindowPosition() { let position: WindowPosition try { position = JSON.parse(await fs.promises.readFile(path.join(Paths.userDataDir, 'window-position.json'), 'utf8')) } catch (e) { return { bounds: { width: WINDOW_DEFAULT_WIDTH, height: WINDOW_DEFAULT_HEIGHT, }, } } const options: Partial = {} const bounds = position.bounds if (bounds) { /** Validate coordinates. Keep them if the window can fit on a screen */ const area = screen.getDisplayMatching(bounds).workArea if ( bounds.x >= area.x && bounds.y >= area.y && bounds.x + bounds.width <= area.x + area.width && bounds.y + bounds.height <= area.y + area.height ) { options.x = bounds.x options.y = bounds.y } if (bounds.width <= area.width || bounds.height <= area.height) { options.width = bounds.width options.height = bounds.height } } return { isMaximized: position.isMaximized, isFullScreen: position.isFullScreen, bounds: { width: WINDOW_DEFAULT_WIDTH, height: WINDOW_DEFAULT_HEIGHT, ...options, }, } } function persistWindowPosition(window: BrowserWindow) { let writingToDisk = false const saveWindowBounds = debounce(async () => { const position: WindowPosition = { bounds: window.getNormalBounds(), isMaximized: window.isMaximized(), isFullScreen: window.isFullScreen(), } if (writingToDisk) { return } writingToDisk = true try { await fs.promises.writeFile(Paths.windowPositionJson, JSON.stringify(position), 'utf-8') } catch (error) { console.error('Could not write to window-position.json', error) } finally { writingToDisk = false } }, 500) window.on('resize', saveWindowBounds) window.on('move', saveWindowBounds) }