diff --git a/src/services/windows/WindowProperties.ts b/src/services/windows/WindowProperties.ts index a900c086..c805ccf8 100644 --- a/src/services/windows/WindowProperties.ts +++ b/src/services/windows/WindowProperties.ts @@ -102,6 +102,7 @@ export interface WindowMeta extends Record [WindowNames.codeInjection]: { codeInjectionType?: CodeInjectionType }; [WindowNames.editWorkspace]: { workspaceID?: string }; [WindowNames.openUrlWith]: { incomingUrl?: string }; + [WindowNames.main]: { forceClose?: boolean }; } /** diff --git a/src/services/windows/handleAttachToMenuBar.ts b/src/services/windows/handleAttachToMenuBar.ts new file mode 100644 index 00000000..8682d883 --- /dev/null +++ b/src/services/windows/handleAttachToMenuBar.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/strict-boolean-expressions */ +import { Menu, Tray, ipcMain, nativeImage } from 'electron'; +import windowStateKeeper from 'electron-window-state'; +import { menubar, Menubar } from 'menubar'; +import path from 'path'; + +import { REACT_PATH, isDev as isDevelopment, buildResourcePath } from '@services/constants/paths'; + +export default async function handleAttachToMenuBar(): Promise { + const menubarWindowState = windowStateKeeper({ + file: 'window-state-menubar.json', + defaultWidth: 400, + defaultHeight: 400, + }); + + // setImage after Tray instance is created to avoid + // "Segmentation fault (core dumped)" bug on Linux + // https://github.com/electron/electron/issues/22137#issuecomment-586105622 + // https://github.com/atomery/translatium/issues/164 + const tray = new Tray(nativeImage.createEmpty()); + // icon template is not supported on Windows & Linux + const iconPath = path.resolve(buildResourcePath, process.platform === 'darwin' ? 'menubarTemplate.png' : 'menubar.png'); + tray.setImage(iconPath); + + const menuBar = menubar({ + index: REACT_PATH, + tray, + preloadWindow: true, + tooltip: 'TiddlyGit', + browserWindow: { + x: menubarWindowState.x, + y: menubarWindowState.y, + width: menubarWindowState.width, + height: menubarWindowState.height, + minHeight: 100, + minWidth: 250, + webPreferences: { + nodeIntegration: false, + enableRemoteModule: true, + webSecurity: !isDevelopment, + contextIsolation: true, + preload: path.join(__dirname, '..', 'preload', 'menubar.js'), + }, + }, + }); + + menuBar.on('after-create-window', () => { + if (menuBar.window) { + menubarWindowState.manage(menuBar.window); + + menuBar.window.on('focus', () => { + const view = menuBar.window?.getBrowserView(); + if (view?.webContents) { + view.webContents.focus(); + } + }); + } + }); + + return await new Promise((resolve) => { + menuBar.on('ready', () => { + menuBar.tray.on('right-click', () => { + // TODO: restore updater options here + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Open TiddlyGit', + click: async () => await menuBar.showWindow(), + }, + { + type: 'separator', + }, + { + label: 'About TiddlyGit', + click: () => ipcMain.emit('request-show-about-window'), + }, + { type: 'separator' }, + { + label: 'Preferences...', + click: () => ipcMain.emit('request-show-preferences-window'), + }, + { type: 'separator' }, + { + label: 'Notifications...', + click: () => ipcMain.emit('request-show-notifications-window'), + }, + { type: 'separator' }, + { + label: 'Clear Browsing Data...', + click: () => ipcMain.emit('request-clear-browsing-data'), + }, + { type: 'separator' }, + { + role: 'quit', + click: () => { + menuBar.app.quit(); + }, + }, + ]); + + menuBar.tray.popUpContextMenu(contextMenu); + }); + + resolve(menuBar); + }); + }); +} diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index 610e7c09..a72d1f72 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -1,18 +1,21 @@ /* eslint-disable @typescript-eslint/consistent-type-assertions */ -import { BrowserWindow, ipcMain, dialog, app, App, remote, clipboard } from 'electron'; +import { BrowserWindow, ipcMain, dialog, app, App, remote, clipboard, BrowserWindowConstructorOptions } from 'electron'; import isDevelopment from 'electron-is-dev'; import { injectable, inject } from 'inversify'; +import windowStateKeeper, { State as windowStateKeeperState } from 'electron-window-state'; import { IBrowserViewMetaData, WindowNames, windowDimension, WindowMeta, CodeInjectionType } from '@services/windows/WindowProperties'; import serviceIdentifiers from '@services/serviceIdentifier'; import { Preference } from '@services/preferences'; import { Workspace } from '@services/workspaces'; +import { WorkspaceView } from '@services/workspacesView'; import { MenuService } from '@services/menu'; import { Channels, WindowChannel, MetaDataChannel } from '@/constants/channels'; import i18n from '@services/libs/i18n'; import getViewBounds from '@services/libs/get-view-bounds'; import getFromRenderer from '@services/libs/getFromRenderer'; +import handleAttachToMenuBar from './handleAttachToMenuBar'; @injectable() export class Window { @@ -22,6 +25,7 @@ export class Window { constructor( @inject(serviceIdentifiers.Preference) private readonly preferenceService: Preference, @inject(serviceIdentifiers.Workspace) private readonly workspaceService: Workspace, + @inject(serviceIdentifiers.WorkspaceView) private readonly workspaceViewService: WorkspaceView, @inject(serviceIdentifiers.MenuService) private readonly menuService: MenuService, ) { this.initIPCHandlers(); @@ -29,53 +33,6 @@ export class Window { } initIPCHandlers(): void { - ipcMain.handle('request-go-home', async (_event: Electron.IpcMainInvokeEvent, windowName: WindowNames = WindowNames.main) => { - const win = this.get(windowName); - const contents = win?.getBrowserView()?.webContents; - const activeWorkspace = this.workspaceService.getActiveWorkspace(); - if (contents !== undefined && activeWorkspace !== undefined && win !== undefined) { - await contents.loadURL(activeWorkspace.homeUrl); - contents.send('update-can-go-back', contents.canGoBack()); - contents.send('update-can-go-forward', contents.canGoForward()); - } - }); - ipcMain.handle('request-go-back', (_event: Electron.IpcMainInvokeEvent, windowName: WindowNames = WindowNames.main) => { - const win = this.get(windowName); - const contents = win?.getBrowserView()?.webContents; - if (contents?.canGoBack() === true) { - contents.goBack(); - contents.send('update-can-go-back', contents.canGoBack()); - contents.send('update-can-go-forward', contents.canGoForward()); - } - }); - ipcMain.handle('request-go-forward', (_event: Electron.IpcMainInvokeEvent, windowName: WindowNames = WindowNames.main) => { - const win = this.get(windowName); - const contents = win?.getBrowserView()?.webContents; - if (contents?.canGoForward() === true) { - contents.goForward(); - contents.send('update-can-go-back', contents.canGoBack()); - contents.send('update-can-go-forward', contents.canGoForward()); - } - }); - ipcMain.handle('request-reload', (_event: Electron.IpcMainInvokeEvent, windowName: WindowNames = WindowNames.main) => { - const win = this.get(windowName); - win?.getBrowserView()?.webContents?.reload(); - }); - ipcMain.handle('request-show-message-box', (_event, message: Electron.MessageBoxOptions['message'], type?: Electron.MessageBoxOptions['type']) => { - const mainWindow = this.get(WindowNames.main); - if (mainWindow !== undefined) { - dialog - .showMessageBox(mainWindow, { - type: type ?? 'error', - message, - buttons: ['OK'], - cancelId: 0, - defaultId: 0, - }) - .catch(console.log); - } - }); - ipcMain.handle(WindowChannel.requestShowRequireRestartDialog, () => { const availableWindowToShowDialog = this.get(WindowNames.preferences) ?? this.get(WindowNames.main); if (availableWindowToShowDialog !== undefined) { @@ -180,16 +137,47 @@ export class Window { const existedWindow = this.windows[windowName]; const existedWindowMeta = this.windowMeta[windowName]; if (existedWindow !== undefined) { + // TODO: handle this menubar logic + // if (attachToMenubar) { + // if (menuBar == undefined) { + // createAsync(); + // } else { + // menuBar.on('ready', () => { + // menuBar.showWindow(); + // }); + // } + // } if (recreate === true || (typeof recreate === 'function' && existedWindowMeta !== undefined && recreate(existedWindowMeta))) { existedWindow.close(); } else { return existedWindow.show(); } } + const attachToMenubar: boolean = this.preferenceService.get('attachToMenubar'); + let mainWindowConfig: Partial = {}; + let mainWindowState: windowStateKeeperState | undefined; + const isMainWindow = windowName === WindowNames.main; + if (isMainWindow) { + if (attachToMenubar) { + await handleAttachToMenuBar(); + } + + mainWindowState = windowStateKeeper({ + defaultWidth: windowDimension[WindowNames.main].width, + defaultHeight: windowDimension[WindowNames.main].height, + }); + mainWindowConfig = { + x: mainWindowState.x, + y: mainWindowState.y, + width: mainWindowState.width, + height: mainWindowState.height, + }; + } const newWindow = new BrowserWindow({ ...windowDimension[windowName], + ...mainWindowConfig, resizable: false, maximizable: false, minimizable: false, @@ -205,15 +193,107 @@ export class Window { }, parent: windowName === WindowNames.main || attachToMenubar ? undefined : this.get(WindowNames.main), }); - newWindow.setMenuBarVisibility(false); + this.windows[windowName] = newWindow; + if (isMainWindow) { + mainWindowState?.manage(newWindow); + await this.registerMainWindowListeners(newWindow); + } else { + newWindow.setMenuBarVisibility(false); + } newWindow.on('closed', () => { this.windows[windowName] = undefined; }); - this.windows[windowName] = newWindow; return newWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); } + private async registerMainWindowListeners(newWindow: BrowserWindow): Promise { + const { wasOpenedAsHidden } = app.getLoginItemSettings(); + // Enable swipe to navigate + const swipeToNavigate = this.preferenceService.get('swipeToNavigate'); + if (swipeToNavigate) { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + mainWindow.on('swipe', (_event, direction) => { + const view = mainWindow?.getBrowserView(); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (view) { + if (direction === 'left') { + view.webContents.goBack(); + } else if (direction === 'right') { + view.webContents.goForward(); + } + } + }); + } + + // Hide window instead closing on macos + newWindow.on('close', (event) => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + if (process.platform === 'darwin' && this.getWindowMeta(WindowNames.main).forceClose !== true) { + event.preventDefault(); + // https://github.com/electron/electron/issues/6033#issuecomment-242023295 + if (mainWindow.isFullScreen()) { + mainWindow.once('leave-full-screen', () => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow !== undefined) { + mainWindow.hide(); + } + }); + mainWindow.setFullScreen(false); + } else { + mainWindow.hide(); + } + } + }); + + newWindow.on('focus', () => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + const view = mainWindow?.getBrowserView(); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + view?.webContents?.focus(); + }); + + newWindow.once('ready-to-show', () => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + if (!wasOpenedAsHidden) { + mainWindow.show(); + } + + // calling this to redundantly setBounds BrowserView + // after the UI is fully loaded + // if not, BrowserView mouseover event won't work correctly + // https://github.com/atomery/webcatalog/issues/812 + this.workspaceViewService.realignActiveWorkspace(); + }); + + newWindow.on('enter-full-screen', () => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + mainWindow?.webContents.send('is-fullscreen-updated', true); + this.workspaceViewService.realignActiveWorkspace(); + }); + newWindow.on('leave-full-screen', () => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + mainWindow?.webContents.send('is-fullscreen-updated', false); + this.workspaceViewService.realignActiveWorkspace(); + }); + + return await new Promise((resolve) => { + const mainWindow = this.get(WindowNames.main); + if (mainWindow === undefined) return; + // ensure redux is loaded first + // if not, redux might not be able catch changes sent from ipcMain + mainWindow.webContents.once('did-stop-loading', () => { + resolve(); + }); + }); + } + public setWindowMeta(windowName: N, meta: WindowMeta[N]): void { this.windowMeta[windowName] = meta; } @@ -238,6 +318,57 @@ export class Window { }); }; + public async goHome(windowName: WindowNames = WindowNames.main): Promise { + const win = this.get(windowName); + const contents = win?.getBrowserView()?.webContents; + const activeWorkspace = this.workspaceService.getActiveWorkspace(); + if (contents !== undefined && activeWorkspace !== undefined && win !== undefined) { + await contents.loadURL(activeWorkspace.homeUrl); + contents.send('update-can-go-back', contents.canGoBack()); + contents.send('update-can-go-forward', contents.canGoForward()); + } + } + + public goBack(windowName: WindowNames = WindowNames.main): void { + const win = this.get(windowName); + const contents = win?.getBrowserView()?.webContents; + if (contents?.canGoBack() === true) { + contents.goBack(); + contents.send('update-can-go-back', contents.canGoBack()); + contents.send('update-can-go-forward', contents.canGoForward()); + } + } + + public goForward(windowName: WindowNames = WindowNames.main): void { + const win = this.get(windowName); + const contents = win?.getBrowserView()?.webContents; + if (contents?.canGoForward() === true) { + contents.goForward(); + contents.send('update-can-go-back', contents.canGoBack()); + contents.send('update-can-go-forward', contents.canGoForward()); + } + } + + public reload(windowName: WindowNames = WindowNames.main): void { + const win = this.get(windowName); + win?.getBrowserView()?.webContents?.reload(); + } + + public showMessageBox(message: Electron.MessageBoxOptions['message'], type?: Electron.MessageBoxOptions['type']): void { + const mainWindow = this.get(WindowNames.main); + if (mainWindow !== undefined) { + dialog + .showMessageBox(mainWindow, { + type: type ?? 'error', + message, + buttons: ['OK'], + cancelId: 0, + defaultId: 0, + }) + .catch(console.log); + } + } + private registerMenu(): void { this.menuService.insertMenu( 'window', @@ -368,6 +499,7 @@ export class Window { ]); if (process.platform === 'darwin') { + // TODO: restore updater options here this.menuService.insertMenu('TiddlyGit', [ { label: i18n.t('ContextMenu.About'), diff --git a/src/services/windows/main.ts b/src/services/windows/main.ts deleted file mode 100644 index bfbd7532..00000000 --- a/src/services/windows/main.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { BrowserWindow, Menu, Tray, app, ipcMain, nativeImage } from 'electron'; -import windowStateKeeper from 'electron-window-state'; -import { menubar, Menubar } from 'menubar'; -import path from 'path'; - -import { REACT_PATH, isDev as isDevelopment, buildResourcePath } from '@services/constants/paths'; -import { getPreference } from '../libs/preferences'; -import formatBytes from '../libs/format-bytes'; - -let win: BrowserWindow | undefined; -let menuBar: Menubar; -let attachToMenubar = false; - -export const get = (): BrowserWindow | undefined => { - if (attachToMenubar && menuBar) return menuBar.window; - return win; -}; - -export const createAsync = async (): Promise => - await new Promise((resolve) => { - attachToMenubar = getPreference('attachToMenubar'); - if (attachToMenubar) { - const menubarWindowState = windowStateKeeper({ - file: 'window-state-menubar.json', - defaultWidth: 400, - defaultHeight: 400, - }); - - // setImage after Tray instance is created to avoid - // "Segmentation fault (core dumped)" bug on Linux - // https://github.com/electron/electron/issues/22137#issuecomment-586105622 - // https://github.com/atomery/translatium/issues/164 - const tray = new Tray(nativeImage.createEmpty()); - // icon template is not supported on Windows & Linux - const iconPath = path.resolve(buildResourcePath, process.platform === 'darwin' ? 'menubarTemplate.png' : 'menubar.png'); - tray.setImage(iconPath); - - menuBar = menubar({ - index: REACT_PATH, - tray, - preloadWindow: true, - tooltip: 'TiddlyGit', - browserWindow: { - x: menubarWindowState.x, - y: menubarWindowState.y, - width: menubarWindowState.width, - height: menubarWindowState.height, - minHeight: 100, - minWidth: 250, - webPreferences: { - nodeIntegration: false, - enableRemoteModule: true, - webSecurity: !isDevelopment, - contextIsolation: true, - preload: path.join(__dirname, '..', 'preload', 'menubar.js'), - }, - }, - }); - - menuBar.on('after-create-window', () => { - if (menuBar.window) { - menubarWindowState.manage(menuBar.window); - - menuBar.window.on('focus', () => { - const view = menuBar.window?.getBrowserView(); - if (view && view.webContents) { - view.webContents.focus(); - } - }); - } - }); - - menuBar.on('ready', () => { - menuBar.tray.on('right-click', () => { - const updaterEnabled = process.env.SNAP == undefined && !process.mas && !process.windowsStore; - - const updaterMenuItem = { - label: 'Check for Updates...', - click: () => ipcMain.emit('request-check-for-updates'), - visible: updaterEnabled, - enabled: true, - }; - if (global.updaterObj && global.updaterObj.status === 'update-downloaded') { - updaterMenuItem.label = 'Restart to Apply Updates...'; - } else if (global.updaterObj && global.updaterObj.status === 'update-available') { - updaterMenuItem.label = 'Downloading Updates...'; - updaterMenuItem.enabled = false; - } else if (global.updaterObj && global.updaterObj.status === 'download-progress') { - const { transferred, total, bytesPerSecond } = global.updaterObj.info; - updaterMenuItem.label = `Downloading Updates (${formatBytes(transferred)}/${formatBytes(total)} at ${formatBytes(bytesPerSecond)}/s)...`; - updaterMenuItem.enabled = false; - } else if (global.updaterObj && global.updaterObj.status === 'checking-for-update') { - updaterMenuItem.label = 'Checking for Updates...'; - updaterMenuItem.enabled = false; - } - - const contextMenu = Menu.buildFromTemplate([ - { - label: 'Open TiddlyGit', - click: () => menuBar.showWindow(), - }, - { - type: 'separator', - }, - { - label: 'About TiddlyGit', - click: () => ipcMain.emit('request-show-about-window'), - }, - { type: 'separator' }, - updaterMenuItem, - { type: 'separator' }, - { - label: 'Preferences...', - click: () => ipcMain.emit('request-show-preferences-window'), - }, - { type: 'separator' }, - { - label: 'Notifications...', - click: () => ipcMain.emit('request-show-notifications-window'), - }, - { type: 'separator' }, - { - label: 'Clear Browsing Data...', - click: () => ipcMain.emit('request-clear-browsing-data'), - }, - { type: 'separator' }, - { - role: 'quit', - click: () => { - menuBar.app.quit(); - }, - }, - ]); - - menuBar.tray.popUpContextMenu(contextMenu); - }); - - resolve(); - }); - return; - } - - const { wasOpenedAsHidden } = app.getLoginItemSettings(); - - const mainWindowState = windowStateKeeper({ - defaultWidth: 1000, - defaultHeight: 768, - }); - - win = new BrowserWindow({ - x: mainWindowState.x, - y: mainWindowState.y, - width: mainWindowState.width, - height: mainWindowState.height, - minHeight: 100, - minWidth: 350, - title: 'TiddlyGit', - titleBarStyle: 'hidden', - show: false, - // manually set dock icon for AppImage - // Snap icon is set correct already so no need to intervene - icon: process.platform === 'linux' && process.env.SNAP == undefined ? path.resolve(buildResourcePath, 'icon.png') : undefined, - webPreferences: { - nodeIntegration: false, - enableRemoteModule: true, - webSecurity: !isDevelopment, - contextIsolation: true, - // @ts-expect-error ts-migrate(2304) FIXME: Cannot find name 'MAIN_WINDOW_PRELOAD_WEBPACK_ENTR... Remove this comment to see the full error message - preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, - }, - }); - if (getPreference('hideMenuBar')) { - win.setMenuBarVisibility(false); - } - - mainWindowState.manage(win); - - // Enable swipe to navigate - const swipeToNavigate = getPreference('swipeToNavigate'); - if (swipeToNavigate) { - win.on('swipe', (e, direction) => { - const view = win?.getBrowserView(); - if (view) { - if (direction === 'left') { - view.webContents.goBack(); - } else if (direction === 'right') { - view.webContents.goForward(); - } - } - }); - } - - // Hide window instead closing on macos - win.on('close', (e) => { - // FIXME: use custom property - if (win && process.platform === 'darwin' && !(win as any).forceClose) { - e.preventDefault(); - // https://github.com/electron/electron/issues/6033#issuecomment-242023295 - if (win.isFullScreen()) { - win.once('leave-full-screen', () => { - if (win) { - win.hide(); - } - }); - win.setFullScreen(false); - } else { - win.hide(); - } - } - }); - - win.on('closed', () => { - win = undefined; - }); - - win.on('focus', () => { - const view = win?.getBrowserView(); - if (view && view.webContents) { - view.webContents.focus(); - } - }); - - win.once('ready-to-show', () => { - if (win && !wasOpenedAsHidden) { - win.show(); - } - - // calling this to redundantly setBounds BrowserView - // after the UI is fully loaded - // if not, BrowserView mouseover event won't work correctly - // https://github.com/atomery/webcatalog/issues/812 - this.workspaceViewService.realignActiveWorkspace(); - }); - - win.on('enter-full-screen', () => { - win?.webContents.send('is-fullscreen-updated', true); - this.workspaceViewService.realignActiveWorkspace(); - }); - win.on('leave-full-screen', () => { - win?.webContents.send('is-fullscreen-updated', false); - this.workspaceViewService.realignActiveWorkspace(); - }); - - // ensure redux is loaded first - // if not, redux might not be able catch changes sent from ipcMain - win.webContents.once('did-stop-loading', () => { - resolve(); - }); - - // @ts-expect-error ts-migrate(2304) FIXME: Cannot find name 'MAIN_WINDOW_WEBPACK_ENTRY'. - void win.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); - }); - -export const show = () => { - if (attachToMenubar) { - if (menuBar == undefined) { - createAsync(); - } else { - menuBar.on('ready', () => { - menuBar.showWindow(); - }); - } - } else if (win == undefined) { - createAsync(); - } else { - win.show(); - } -};