Electron: Hide to tray on close in Windows (#3828)

This commit is contained in:
Alexander Zinchuk 2023-09-25 12:59:50 +02:00
parent 1f10dca00e
commit af69e04e31
10 changed files with 191 additions and 41 deletions

View File

@ -17,6 +17,7 @@ src/lib/secret-sauce/
playwright.config.ts playwright.config.ts
dist dist
dist-electron
public public
deploy/update_version.js deploy/update_version.js

View File

@ -1,7 +1,6 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, memo, useCallback, useEffect, useState,
useCallback,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -9,17 +8,19 @@ import type { ISettings, TimeFormat } from '../../../types';
import type { IRadioOption } from '../../ui/RadioGroup'; import type { IRadioOption } from '../../ui/RadioGroup';
import { SettingsScreens } from '../../../types'; import { SettingsScreens } from '../../../types';
import { IS_ELECTRON } from '../../../config';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import { setTimeFormat } from '../../../util/langProvider'; import { setTimeFormat } from '../../../util/langProvider';
import { getSystemTheme } from '../../../util/systemTheme'; import { getSystemTheme } from '../../../util/systemTheme';
import { import {
IS_ANDROID, IS_IOS, IS_MAC_OS, IS_ANDROID, IS_IOS, IS_MAC_OS, IS_WINDOWS,
} from '../../../util/windowEnvironment'; } from '../../../util/windowEnvironment';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem'; import ListItem from '../../ui/ListItem';
import RadioGroup from '../../ui/RadioGroup'; import RadioGroup from '../../ui/RadioGroup';
import RangeSlider from '../../ui/RangeSlider'; import RangeSlider from '../../ui/RangeSlider';
@ -117,6 +118,15 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
setSettingOption({ messageSendKeyCombo: newCombo as ISettings['messageSendKeyCombo'] }); setSettingOption({ messageSendKeyCombo: newCombo as ISettings['messageSendKeyCombo'] });
}, [setSettingOption]); }, [setSettingOption]);
const [isTrayIconEnabled, setIsTrayIconEnabled] = useState(false);
useEffect(() => {
window.electron?.getIsTrayIconEnabled().then(setIsTrayIconEnabled);
}, []);
const handleIsTrayIconEnabledChange = useCallback((isChecked: boolean) => {
window.electron?.setIsTrayIconEnabled(isChecked);
}, []);
useHistoryBack({ useHistoryBack({
isActive, isActive,
onBack: onReset, onBack: onReset,
@ -142,6 +152,14 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
> >
{lang('ChatBackground')} {lang('ChatBackground')}
</ListItem> </ListItem>
{IS_ELECTRON && IS_WINDOWS && (
<Checkbox
label={lang('GeneralSettings.StatusBarItem')}
checked={Boolean(isTrayIconEnabled)}
onCheck={handleIsTrayIconEnabledChange}
/>
)}
</div> </div>
<div className="settings-item"> <div className="settings-item">

View File

@ -7,6 +7,7 @@ extraMetadata:
files: files:
- "dist" - "dist"
- "package.json" - "package.json"
- "public/icon-electron-windows.ico"
- "!dist/**/build-stats.json" - "!dist/**/build-stats.json"
- "!dist/**/statoscope-report.html" - "!dist/**/statoscope-report.html"
- "!dist/**/reference.json" - "!dist/**/reference.json"

View File

@ -4,7 +4,7 @@ import path from 'path';
import { ElectronEvent } from '../types/electron'; import { ElectronEvent } from '../types/electron';
import { import {
IS_LINUX, IS_MAC_OS, IS_WINDOWS, windows, getLastWindow, IS_LINUX, IS_MAC_OS, IS_WINDOWS,
} from './utils'; } from './utils';
const TG_PROTOCOL = 'tg'; const TG_PROTOCOL = 'tg';
@ -12,7 +12,7 @@ const TG_PROTOCOL = 'tg';
let deeplinkUrl: string | undefined; let deeplinkUrl: string | undefined;
export function initDeeplink() { export function initDeeplink() {
const currentWindow = Array.from(windows).pop(); const window = getLastWindow();
if (process.defaultApp) { if (process.defaultApp) {
if (process.argv.length >= 2) { if (process.argv.length >= 2) {
@ -51,24 +51,24 @@ export function initDeeplink() {
processDeeplink(); processDeeplink();
if (currentWindow) { if (window) {
if (currentWindow.isMinimized()) { if (window.isMinimized()) {
currentWindow.restore(); window.restore();
} }
currentWindow.focus(); window.focus();
} }
}); });
} }
export function processDeeplink() { export function processDeeplink() {
const currentWindow = Array.from(windows).pop(); const window = getLastWindow();
if (!currentWindow || !deeplinkUrl) { if (!window || !deeplinkUrl) {
return; return;
} }
currentWindow.webContents.send(ElectronEvent.DEEPLINK, deeplinkUrl); window.webContents.send(ElectronEvent.DEEPLINK, deeplinkUrl);
deeplinkUrl = undefined; deeplinkUrl = undefined;
} }

View File

@ -12,6 +12,8 @@ const electronApi: ElectronApi = {
setWindowTitle: (title?: string) => ipcRenderer.invoke(ElectronAction.SET_WINDOW_TITLE, title), setWindowTitle: (title?: string) => ipcRenderer.invoke(ElectronAction.SET_WINDOW_TITLE, title),
setTrafficLightPosition: setTrafficLightPosition:
(position: TrafficLightPosition) => ipcRenderer.invoke(ElectronAction.SET_TRAFFIC_LIGHT_POSITION, position), (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) => { on: (eventName: ElectronEvent, callback) => {
const subscription = (event: IpcRendererEvent, ...args: any) => callback(...args); const subscription = (event: IpcRendererEvent, ...args: any) => callback(...args);

103
src/electron/tray.ts Normal file
View File

@ -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;

View File

@ -1,14 +1,28 @@
import type { BrowserWindow, Point } from 'electron'; import type { Point } from 'electron';
import { app } from 'electron'; import { app, BrowserWindow } from 'electron';
import Store from 'electron-store';
import type { TrafficLightPosition } from '../types/electron'; import type { TrafficLightPosition } from '../types/electron';
export const windows = new Set<BrowserWindow>();
export const IS_MAC_OS = process.platform === 'darwin'; export const IS_MAC_OS = process.platform === 'darwin';
export const IS_WINDOWS = process.platform === 'win32'; export const IS_WINDOWS = process.platform === 'win32';
export const IS_LINUX = process.platform === 'linux'; export const IS_LINUX = process.platform === 'linux';
export const windows = new Set<BrowserWindow>();
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 { export function getAppTitle(chatTitle?: string): string {
const appName = app.getName(); const appName = app.getName();

View File

@ -9,8 +9,10 @@ import { ElectronAction, ElectronEvent } from '../types/electron';
import setupAutoUpdates from './autoUpdates'; import setupAutoUpdates from './autoUpdates';
import { processDeeplink } from './deeplink'; import { processDeeplink } from './deeplink';
import tray from './tray';
import { 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'; } from './utils';
import windowStateKeeper from './windowState'; import windowStateKeeper from './windowState';
@ -25,7 +27,7 @@ export function createWindow(url?: string) {
let x; let x;
let y; let y;
const currentWindow = BrowserWindow.getFocusedWindow(); const currentWindow = getCurrentWindow();
if (currentWindow) { if (currentWindow) {
const [currentWindowX, currentWindowY] = currentWindow.getPosition(); const [currentWindowX, currentWindowY] = currentWindow.getPosition();
x = currentWindowX + 24; x = currentWindowX + 24;
@ -90,20 +92,16 @@ export function createWindow(url?: string) {
}); });
window.on('close', (event) => { window.on('close', (event) => {
if (IS_MAC_OS) { if (IS_MAC_OS || IS_WINDOWS) {
if (forceQuit.isEnabled) { if (forceQuit.isEnabled) {
app.exit(0); app.exit(0);
forceQuit.disable(); forceQuit.disable();
} else if (hasExtraWindows()) {
windows.delete(window);
windowState.unmanage();
} else { } else {
const hasExtraWindows = BrowserWindow.getAllWindows().length > 1; event.preventDefault();
window.hide();
if (hasExtraWindows) {
windows.delete(window);
windowState.unmanage();
} else {
event.preventDefault();
window.hide();
}
} }
} }
}); });
@ -123,6 +121,11 @@ export function createWindow(url?: string) {
window.removeMenu(); window.removeMenu();
} }
if (IS_WINDOWS && tray.isEnabled) {
tray.setupListeners(window);
tray.create();
}
window.webContents.once('dom-ready', () => { window.webContents.once('dom-ready', () => {
window.show(); window.show();
processDeeplink(); processDeeplink();
@ -141,17 +144,15 @@ export function setupElectronActionHandlers() {
}); });
ipcMain.handle(ElectronAction.SET_WINDOW_TITLE, (_, newTitle?: string) => { ipcMain.handle(ElectronAction.SET_WINDOW_TITLE, (_, newTitle?: string) => {
const currentWindow = BrowserWindow.getFocusedWindow(); getCurrentWindow()?.setTitle(getAppTitle(newTitle));
currentWindow?.setTitle(getAppTitle(newTitle));
}); });
ipcMain.handle(ElectronAction.GET_IS_FULLSCREEN, () => { ipcMain.handle(ElectronAction.GET_IS_FULLSCREEN, () => {
const currentWindow = BrowserWindow.getFocusedWindow(); getCurrentWindow()?.isFullScreen();
currentWindow?.isFullScreen();
}); });
ipcMain.handle(ElectronAction.HANDLE_DOUBLE_CLICK, () => { ipcMain.handle(ElectronAction.HANDLE_DOUBLE_CLICK, () => {
const currentWindow = BrowserWindow.getFocusedWindow(); const currentWindow = getCurrentWindow();
const doubleClickAction = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); const doubleClickAction = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');
if (doubleClickAction === 'Minimize') { if (doubleClickAction === 'Minimize') {
@ -170,9 +171,18 @@ export function setupElectronActionHandlers() {
return; return;
} }
const currentWindow = BrowserWindow.getFocusedWindow(); getCurrentWindow()?.setTrafficLightPosition(TRAFFIC_LIGHT_POSITION[position]);
currentWindow?.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() { export function setupCloseHandlers() {
@ -197,9 +207,7 @@ export function setupCloseHandlers() {
createWindow(); createWindow();
} else if (IS_MAC_OS) { } else if (IS_MAC_OS) {
forceQuit.disable(); forceQuit.disable();
getLastWindow()?.show();
const currentWindow = Array.from(windows).pop();
currentWindow?.show();
} }
}); });
} }

View File

@ -1,6 +1,7 @@
import type { BrowserWindow, Rectangle } from 'electron'; import type { BrowserWindow, Rectangle } from 'electron';
import { screen } from 'electron'; import { screen } from 'electron';
import Store from 'electron-store';
import { store } from './utils';
type Options = { type Options = {
defaultHeight?: number; defaultHeight?: number;
@ -40,8 +41,6 @@ const DEFAULT_OPTIONS = {
fullScreen: true, fullScreen: true,
}; };
const store: Store = new Store();
function windowStateKeeper(options: Options): WindowState { function windowStateKeeper(options: Options): WindowState {
let state: State; let state: State;
let winRef: BrowserWindow | undefined; let winRef: BrowserWindow | undefined;

View File

@ -12,6 +12,8 @@ export enum ElectronAction {
OPEN_NEW_WINDOW = 'open-new-window', OPEN_NEW_WINDOW = 'open-new-window',
SET_WINDOW_TITLE = 'set-window-title', SET_WINDOW_TITLE = 'set-window-title',
SET_TRAFFIC_LIGHT_POSITION = 'set-traffic-light-position', 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'; export type TrafficLightPosition = 'standard' | 'lowered';
@ -23,6 +25,8 @@ export interface ElectronApi {
openNewWindow: (url: string, title?: string) => Promise<void>; openNewWindow: (url: string, title?: string) => Promise<void>;
setWindowTitle: (title?: string) => Promise<void>; setWindowTitle: (title?: string) => Promise<void>;
setTrafficLightPosition: (position: TrafficLightPosition) => Promise<void>; setTrafficLightPosition: (position: TrafficLightPosition) => Promise<void>;
setIsTrayIconEnabled: (value: boolean) => Promise<void>;
getIsTrayIconEnabled: () => Promise<boolean>;
on: (eventName: ElectronEvent, callback: any) => VoidFunction; on: (eventName: ElectronEvent, callback: any) => VoidFunction;
} }