diff --git a/.eslintignore b/.eslintignore index ff09245b1..2d7929d83 100644 --- a/.eslintignore +++ b/.eslintignore @@ -17,6 +17,7 @@ src/lib/secret-sauce/ playwright.config.ts dist +dist-electron public deploy/update_version.js diff --git a/src/components/left/settings/SettingsGeneral.tsx b/src/components/left/settings/SettingsGeneral.tsx index 4f6319f34..2dd4e2735 100644 --- a/src/components/left/settings/SettingsGeneral.tsx +++ b/src/components/left/settings/SettingsGeneral.tsx @@ -1,7 +1,6 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, - useCallback, + memo, useCallback, useEffect, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; @@ -9,17 +8,19 @@ import type { ISettings, TimeFormat } from '../../../types'; import type { IRadioOption } from '../../ui/RadioGroup'; import { SettingsScreens } from '../../../types'; +import { IS_ELECTRON } from '../../../config'; import { pick } from '../../../util/iteratees'; import { setTimeFormat } from '../../../util/langProvider'; import { getSystemTheme } from '../../../util/systemTheme'; import { - IS_ANDROID, IS_IOS, IS_MAC_OS, + IS_ANDROID, IS_IOS, IS_MAC_OS, IS_WINDOWS, } from '../../../util/windowEnvironment'; import useAppLayout from '../../../hooks/useAppLayout'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useLang from '../../../hooks/useLang'; +import Checkbox from '../../ui/Checkbox'; import ListItem from '../../ui/ListItem'; import RadioGroup from '../../ui/RadioGroup'; import RangeSlider from '../../ui/RangeSlider'; @@ -117,6 +118,15 @@ const SettingsGeneral: FC = ({ setSettingOption({ messageSendKeyCombo: newCombo as ISettings['messageSendKeyCombo'] }); }, [setSettingOption]); + const [isTrayIconEnabled, setIsTrayIconEnabled] = useState(false); + useEffect(() => { + window.electron?.getIsTrayIconEnabled().then(setIsTrayIconEnabled); + }, []); + + const handleIsTrayIconEnabledChange = useCallback((isChecked: boolean) => { + window.electron?.setIsTrayIconEnabled(isChecked); + }, []); + useHistoryBack({ isActive, onBack: onReset, @@ -142,6 +152,14 @@ const SettingsGeneral: FC = ({ > {lang('ChatBackground')} + + {IS_ELECTRON && IS_WINDOWS && ( + + )}
diff --git a/src/electron/config.yml b/src/electron/config.yml index b6265c36c..cf83e81e8 100644 --- a/src/electron/config.yml +++ b/src/electron/config.yml @@ -7,6 +7,7 @@ extraMetadata: files: - "dist" - "package.json" + - "public/icon-electron-windows.ico" - "!dist/**/build-stats.json" - "!dist/**/statoscope-report.html" - "!dist/**/reference.json" diff --git a/src/electron/deeplink.ts b/src/electron/deeplink.ts index af3f3e38c..47fdbfb37 100644 --- a/src/electron/deeplink.ts +++ b/src/electron/deeplink.ts @@ -4,7 +4,7 @@ import path from 'path'; import { ElectronEvent } from '../types/electron'; import { - IS_LINUX, IS_MAC_OS, IS_WINDOWS, windows, + getLastWindow, IS_LINUX, IS_MAC_OS, IS_WINDOWS, } from './utils'; const TG_PROTOCOL = 'tg'; @@ -12,7 +12,7 @@ const TG_PROTOCOL = 'tg'; let deeplinkUrl: string | undefined; export function initDeeplink() { - const currentWindow = Array.from(windows).pop(); + const window = getLastWindow(); if (process.defaultApp) { if (process.argv.length >= 2) { @@ -51,24 +51,24 @@ export function initDeeplink() { processDeeplink(); - if (currentWindow) { - if (currentWindow.isMinimized()) { - currentWindow.restore(); + if (window) { + if (window.isMinimized()) { + window.restore(); } - currentWindow.focus(); + window.focus(); } }); } export function processDeeplink() { - const currentWindow = Array.from(windows).pop(); + const window = getLastWindow(); - if (!currentWindow || !deeplinkUrl) { + if (!window || !deeplinkUrl) { return; } - currentWindow.webContents.send(ElectronEvent.DEEPLINK, deeplinkUrl); + window.webContents.send(ElectronEvent.DEEPLINK, deeplinkUrl); deeplinkUrl = undefined; } diff --git a/src/electron/preload.ts b/src/electron/preload.ts index db1fde6fa..1f5f86003 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -12,6 +12,8 @@ const electronApi: ElectronApi = { setWindowTitle: (title?: string) => ipcRenderer.invoke(ElectronAction.SET_WINDOW_TITLE, title), setTrafficLightPosition: (position: TrafficLightPosition) => ipcRenderer.invoke(ElectronAction.SET_TRAFFIC_LIGHT_POSITION, position), + setIsTrayIconEnabled: (value: boolean) => ipcRenderer.invoke(ElectronAction.SET_IS_TRAY_ICON_ENABLED, value), + getIsTrayIconEnabled: () => ipcRenderer.invoke(ElectronAction.GET_IS_TRAY_ICON_ENABLED), on: (eventName: ElectronEvent, callback) => { const subscription = (event: IpcRendererEvent, ...args: any) => callback(...args); diff --git a/src/electron/tray.ts b/src/electron/tray.ts new file mode 100644 index 000000000..b7cbf3a42 --- /dev/null +++ b/src/electron/tray.ts @@ -0,0 +1,103 @@ +import { + app, BrowserWindow, Menu, nativeImage, Tray, +} from 'electron'; +import path from 'path'; + +import { + forceQuit, getAppTitle, getLastWindow, store, +} from './utils'; + +const TRAY_ICON_SETTINGS_KEY = 'trayIcon'; +const WINDOW_BLUR_TIMEOUT = 800; + +interface TrayHelper { + instance?: Tray; + lastFocusedWindow?: BrowserWindow; + lastFocusedWindowTimer?: NodeJS.Timer; + setupListeners: (window: BrowserWindow) => void; + create: () => void; + enable: () => void; + disable: () => void; + isEnabled: boolean; +} + +const tray: TrayHelper = { + setupListeners(window: BrowserWindow) { + window.on('focus', () => { + clearTimeout(this.lastFocusedWindowTimer); + this.lastFocusedWindow = window; + }); + + window.on('blur', () => { + this.lastFocusedWindowTimer = setTimeout(() => { + if (this.lastFocusedWindow === window) { + this.lastFocusedWindow = undefined; + } + }, WINDOW_BLUR_TIMEOUT); + }); + + window.on('close', () => { + this.lastFocusedWindow = undefined; + }); + }, + + create() { + if (this.instance) { + return; + } + + const icon = nativeImage.createFromPath(path.resolve(__dirname, '../public/icon-electron-windows.ico')); + const title = getAppTitle(); + + this.instance = new Tray(icon); + + const handleOpenFromTray = () => { + if (BrowserWindow.getAllWindows().every((window) => !window.isVisible())) { + BrowserWindow.getAllWindows().forEach((window) => window.show()); + } else { + getLastWindow()?.focus(); + } + }; + + const handleCloseFromTray = () => { + forceQuit.enable(); + app.quit(); + }; + + const handleTrayClick = () => { + if (this.lastFocusedWindow) { + BrowserWindow.getAllWindows().forEach((window) => window.hide()); + this.lastFocusedWindow = undefined; + } else { + handleOpenFromTray(); + } + }; + + const contextMenu = Menu.buildFromTemplate([ + { label: `Open ${title}`, click: handleOpenFromTray }, + { label: `Quit ${title}`, click: handleCloseFromTray }, + ]); + + this.instance.on('click', handleTrayClick); + this.instance.setContextMenu(contextMenu); + this.instance.setToolTip(title); + this.instance.setTitle(title); + }, + + enable() { + store.set(TRAY_ICON_SETTINGS_KEY, true); + this.create(); + }, + + disable() { + store.set(TRAY_ICON_SETTINGS_KEY, false); + this.instance?.destroy(); + this.instance = undefined; + }, + + get isEnabled(): boolean { + return store.get(TRAY_ICON_SETTINGS_KEY, true) as boolean; + }, +}; + +export default tray; diff --git a/src/electron/utils.ts b/src/electron/utils.ts index d26add0bf..d3367466e 100644 --- a/src/electron/utils.ts +++ b/src/electron/utils.ts @@ -1,14 +1,28 @@ -import type { BrowserWindow, Point } from 'electron'; -import { app } from 'electron'; +import type { Point } from 'electron'; +import { app, BrowserWindow } from 'electron'; +import Store from 'electron-store'; import type { TrafficLightPosition } from '../types/electron'; -export const windows = new Set(); - export const IS_MAC_OS = process.platform === 'darwin'; export const IS_WINDOWS = process.platform === 'win32'; export const IS_LINUX = process.platform === 'linux'; +export const windows = new Set(); +export const store: Store = new Store(); + +export function getCurrentWindow(): BrowserWindow | null { + return BrowserWindow.getFocusedWindow(); +} + +export function getLastWindow(): BrowserWindow | undefined { + return Array.from(windows).pop(); +} + +export function hasExtraWindows(): boolean { + return BrowserWindow.getAllWindows().length > 1; +} + export function getAppTitle(chatTitle?: string): string { const appName = app.getName(); diff --git a/src/electron/window.ts b/src/electron/window.ts index 49f979b7f..9f8be1fac 100644 --- a/src/electron/window.ts +++ b/src/electron/window.ts @@ -9,8 +9,10 @@ import { ElectronAction, ElectronEvent } from '../types/electron'; import setupAutoUpdates from './autoUpdates'; import { processDeeplink } from './deeplink'; +import tray from './tray'; import { - forceQuit, getAppTitle, IS_MAC_OS, TRAFFIC_LIGHT_POSITION, windows, + forceQuit, getAppTitle, getCurrentWindow, getLastWindow, hasExtraWindows, IS_MAC_OS, IS_WINDOWS, + TRAFFIC_LIGHT_POSITION, windows, } from './utils'; import windowStateKeeper from './windowState'; @@ -25,7 +27,7 @@ export function createWindow(url?: string) { let x; let y; - const currentWindow = BrowserWindow.getFocusedWindow(); + const currentWindow = getCurrentWindow(); if (currentWindow) { const [currentWindowX, currentWindowY] = currentWindow.getPosition(); x = currentWindowX + 24; @@ -90,20 +92,16 @@ export function createWindow(url?: string) { }); window.on('close', (event) => { - if (IS_MAC_OS) { + if (IS_MAC_OS || IS_WINDOWS) { if (forceQuit.isEnabled) { app.exit(0); forceQuit.disable(); + } else if (hasExtraWindows()) { + windows.delete(window); + windowState.unmanage(); } else { - const hasExtraWindows = BrowserWindow.getAllWindows().length > 1; - - if (hasExtraWindows) { - windows.delete(window); - windowState.unmanage(); - } else { - event.preventDefault(); - window.hide(); - } + event.preventDefault(); + window.hide(); } } }); @@ -123,6 +121,11 @@ export function createWindow(url?: string) { window.removeMenu(); } + if (IS_WINDOWS && tray.isEnabled) { + tray.setupListeners(window); + tray.create(); + } + window.webContents.once('dom-ready', () => { window.show(); processDeeplink(); @@ -141,17 +144,15 @@ export function setupElectronActionHandlers() { }); ipcMain.handle(ElectronAction.SET_WINDOW_TITLE, (_, newTitle?: string) => { - const currentWindow = BrowserWindow.getFocusedWindow(); - currentWindow?.setTitle(getAppTitle(newTitle)); + getCurrentWindow()?.setTitle(getAppTitle(newTitle)); }); ipcMain.handle(ElectronAction.GET_IS_FULLSCREEN, () => { - const currentWindow = BrowserWindow.getFocusedWindow(); - currentWindow?.isFullScreen(); + getCurrentWindow()?.isFullScreen(); }); ipcMain.handle(ElectronAction.HANDLE_DOUBLE_CLICK, () => { - const currentWindow = BrowserWindow.getFocusedWindow(); + const currentWindow = getCurrentWindow(); const doubleClickAction = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); if (doubleClickAction === 'Minimize') { @@ -170,9 +171,18 @@ export function setupElectronActionHandlers() { return; } - const currentWindow = BrowserWindow.getFocusedWindow(); - currentWindow?.setTrafficLightPosition(TRAFFIC_LIGHT_POSITION[position]); + getCurrentWindow()?.setTrafficLightPosition(TRAFFIC_LIGHT_POSITION[position]); }); + + ipcMain.handle(ElectronAction.SET_IS_TRAY_ICON_ENABLED, (_, value: boolean) => { + if (value) { + tray.enable(); + } else { + tray.disable(); + } + }); + + ipcMain.handle(ElectronAction.GET_IS_TRAY_ICON_ENABLED, () => tray.isEnabled); } export function setupCloseHandlers() { @@ -197,9 +207,7 @@ export function setupCloseHandlers() { createWindow(); } else if (IS_MAC_OS) { forceQuit.disable(); - - const currentWindow = Array.from(windows).pop(); - currentWindow?.show(); + getLastWindow()?.show(); } }); } diff --git a/src/electron/windowState.ts b/src/electron/windowState.ts index 15cd48e59..5455c0edc 100644 --- a/src/electron/windowState.ts +++ b/src/electron/windowState.ts @@ -1,6 +1,7 @@ import type { BrowserWindow, Rectangle } from 'electron'; import { screen } from 'electron'; -import Store from 'electron-store'; + +import { store } from './utils'; type Options = { defaultHeight?: number; @@ -40,8 +41,6 @@ const DEFAULT_OPTIONS = { fullScreen: true, }; -const store: Store = new Store(); - function windowStateKeeper(options: Options): WindowState { let state: State; let winRef: BrowserWindow | undefined; diff --git a/src/types/electron.ts b/src/types/electron.ts index cd443153f..6ea2edcc8 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -12,6 +12,8 @@ export enum ElectronAction { OPEN_NEW_WINDOW = 'open-new-window', SET_WINDOW_TITLE = 'set-window-title', SET_TRAFFIC_LIGHT_POSITION = 'set-traffic-light-position', + SET_IS_TRAY_ICON_ENABLED = 'set-is-tray-icon-enabled', + GET_IS_TRAY_ICON_ENABLED = 'get-is-tray-icon-enabled', } export type TrafficLightPosition = 'standard' | 'lowered'; @@ -23,6 +25,8 @@ export interface ElectronApi { openNewWindow: (url: string, title?: string) => Promise; setWindowTitle: (title?: string) => Promise; setTrafficLightPosition: (position: TrafficLightPosition) => Promise; + setIsTrayIconEnabled: (value: boolean) => Promise; + getIsTrayIconEnabled: () => Promise; on: (eventName: ElectronEvent, callback: any) => VoidFunction; }