add tray and add settings

This commit is contained in:
aurinex
2025-12-16 00:30:00 +05:00
parent cd7ad5039e
commit 6db213d602
12 changed files with 224 additions and 22 deletions

View File

@ -9,7 +9,7 @@
* `./src/main.js` using webpack. This gives us some performance wins. * `./src/main.js` using webpack. This gives us some performance wins.
*/ */
import path from 'path'; import path from 'path';
import { app, BrowserWindow, shell, ipcMain, Tray, Menu, nativeImage } from 'electron'; import { app, BrowserWindow, shell, ipcMain, Tray, Menu, nativeImage, type MenuItemConstructorOptions } from 'electron';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import log from 'electron-log'; import log from 'electron-log';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
@ -71,11 +71,18 @@ const ensureTray = () => {
? path.join(process.resourcesPath, 'assets') ? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets'); : path.join(__dirname, '../../assets');
const iconPath = path.join(RESOURCES_PATH, 'icon.png'); const getAssetPath = (...paths: string[]) => path.join(RESOURCES_PATH, ...paths);
const image = nativeImage.createFromPath(iconPath);
tray = new Tray(image); const trayIconPath = getAssetPath('pop-popa.png'); // или 'Icons/popa-popa.png'
tray.setToolTip('Popa Launcher'); const trayImage = nativeImage.createFromPath(trayIconPath);
tray = new Tray(trayImage);
tray.setToolTip('popa-launcher');
tray.on('click', () => {
if (!mainWindow) return;
mainWindow.show();
mainWindow.focus();
});
const menu = Menu.buildFromTemplate([ const menu = Menu.buildFromTemplate([
{ {
@ -111,9 +118,46 @@ const applyLoginItemSettings = () => {
}; };
let tray: Tray | null = null; let tray: Tray | null = null;
let isAuthed = false;
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
function buildTrayMenu() {
const icon = nativeImage.createFromPath(
app.isPackaged
? path.join(__dirname, '../../assets/popa-popa.png')
: path.join(process.resourcesPath, 'assets', 'popa-popa.png'),
);
const template: MenuItemConstructorOptions[] = [
{ label: 'popa-popa', enabled: false, icon },
{ type: 'separator' },
...(isAuthed
? ([
{ label: 'Новости', click: () => mainWindow?.webContents.send('tray-navigate', '/news') },
{ label: 'Версии', click: () => mainWindow?.webContents.send('tray-navigate', '/') },
{ label: 'Магазин', click: () => mainWindow?.webContents.send('tray-navigate', '/shop') },
{ label: 'Рынок', click: () => mainWindow?.webContents.send('tray-navigate', '/marketplace') },
{ label: 'Профиль', click: () => mainWindow?.webContents.send('tray-navigate', '/profile') },
{ label: 'Настройки', click: () => mainWindow?.webContents.send('tray-navigate', '/settings') },
{ label: 'Ежедневная награда', click: () => mainWindow?.webContents.send('tray-navigate', '/daily') },
{ label: 'Ежедневные квесты', click: () => mainWindow?.webContents.send('tray-navigate', '/dailyquests') },
{ type: 'separator' },
{ label: 'Выйти', click: () => mainWindow?.webContents.send('tray-logout') },
] as MenuItemConstructorOptions[])
: ([
{ label: 'Войти', click: () => mainWindow?.webContents.send('tray-navigate', '/login') },
] as MenuItemConstructorOptions[])),
{ type: 'separator' },
{ label: 'Показать', click: () => { mainWindow?.show(); mainWindow?.focus(); } },
{ label: 'Выход', click: () => app.quit() },
];
tray?.setContextMenu(Menu.buildFromTemplate(template));
}
ipcMain.on('ipc-example', async (event, arg) => { ipcMain.on('ipc-example', async (event, arg) => {
const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`; const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
console.log(msgTemplate(arg)); console.log(msgTemplate(arg));
@ -192,6 +236,8 @@ const createWindow = async () => {
? path.join(process.resourcesPath, 'assets') ? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets'); : path.join(__dirname, '../../assets');
ensureTray();
const getAssetPath = (...paths: string[]): string => { const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths); return path.join(RESOURCES_PATH, ...paths);
}; };
@ -203,7 +249,7 @@ const createWindow = async () => {
autoHideMenuBar: true, autoHideMenuBar: true,
resizable: true, resizable: true,
frame: false, frame: false,
icon: getAssetPath('icon.png'), icon: getAssetPath('popa-popa.png'),
webPreferences: { webPreferences: {
webSecurity: false, webSecurity: false,
preload: app.isPackaged preload: app.isPackaged
@ -286,3 +332,9 @@ app
ipcMain.handle('install-update', () => { ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
}); });
ipcMain.handle('auth-changed', (_e, payload: { isAuthed: boolean }) => {
isAuthed = Boolean(payload?.isAuthed);
buildTrayMenu();
return true;
});

View File

@ -20,6 +20,9 @@ export type Channels =
| 'stop-minecraft' | 'stop-minecraft'
| 'minecraft-started' | 'minecraft-started'
| 'apply-launcher-settings' | 'apply-launcher-settings'
| 'tray-navigate'
| 'tray-logout'
| 'auth-changed'
| 'minecraft-stopped'; | 'minecraft-stopped';
const electronHandler = { const electronHandler = {
@ -42,6 +45,9 @@ const electronHandler = {
invoke(channel: Channels, ...args: unknown[]): Promise<any> { invoke(channel: Channels, ...args: unknown[]): Promise<any> {
return ipcRenderer.invoke(channel, ...args); return ipcRenderer.invoke(channel, ...args);
}, },
removeAllListeners(channel: Channels) {
ipcRenderer.removeAllListeners(channel);
},
}, },
}; };

View File

@ -25,6 +25,7 @@ import { useLocation } from 'react-router-dom';
import DailyReward from './pages/DailyReward'; import DailyReward from './pages/DailyReward';
import DailyQuests from './pages/DailyQuests'; import DailyQuests from './pages/DailyQuests';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import { TrayBridge } from './utils/TrayBridge';
const AuthCheck = ({ children }: { children: ReactNode }) => { const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -205,6 +206,13 @@ const AppLayout = () => {
} }
}, []); }, []);
useEffect(() => {
const raw = localStorage.getItem('launcher_config');
const isAuthed = !!raw && !!JSON.parse(raw).accessToken;
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed });
}, []);
return ( return (
<Box sx={{ width: '100vw', height: '100vh', position: 'relative', overflow: 'hidden' }}> <Box sx={{ width: '100vw', height: '100vh', position: 'relative', overflow: 'hidden' }}>
{/* ФОН — НЕ масштабируется */} {/* ФОН — НЕ масштабируется */}
@ -247,6 +255,7 @@ const AppLayout = () => {
<TopBar onRegister={handleRegister} username={username || ''} /> <TopBar onRegister={handleRegister} username={username || ''} />
<PageHeader /> <PageHeader />
<Notifier /> <Notifier />
<TrayBridge />
<Routes> <Routes>
<Route <Route

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -42,6 +42,8 @@ export default function CoinsDisplay({
sx, sx,
}: CoinsDisplayProps) { }: CoinsDisplayProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [settingsVersion, setSettingsVersion] = useState(0);
const storageKey = useMemo(() => { const storageKey = useMemo(() => {
// ключ под конкретного пользователя // ключ под конкретного пользователя
return username ? `coins:${username}` : 'coins:anonymous'; return username ? `coins:${username}` : 'coins:anonymous';
@ -71,7 +73,24 @@ export default function CoinsDisplay({
return 0; return 0;
}); });
const [isLoading, setIsLoading] = useState<boolean>(false); useEffect(() => {
const handler = () => setSettingsVersion((v) => v + 1);
window.addEventListener('settings-updated', handler as EventListener);
return () => window.removeEventListener('settings-updated', handler as EventListener);
}, []);
const isTooltipDisabledBySettings = useMemo(() => {
try {
const raw = localStorage.getItem('launcher_settings');
if (!raw) return false;
const s = JSON.parse(raw);
return Boolean(s?.disableToolTip);
} catch {
return false;
}
}, [settingsVersion]);
const tooltipEnabled = showTooltip && !isTooltipDisabledBySettings;
const getSizes = () => { const getSizes = () => {
switch (size) { switch (size) {
@ -172,7 +191,7 @@ export default function CoinsDisplay({
borderRadius: sizes.borderRadius, borderRadius: sizes.borderRadius,
padding: sizes.containerPadding, padding: sizes.containerPadding,
border: '1px solid rgba(255, 255, 255, 0.1)', border: '1px solid rgba(255, 255, 255, 0.1)',
cursor: showTooltip ? 'help' : 'default', cursor: tooltipEnabled ? 'help' : 'default',
// можно оставить лёгкий намёк на загрузку, но без "пульса" текста // можно оставить лёгкий намёк на загрузку, но без "пульса" текста
opacity: isLoading ? 0.85 : 1, opacity: isLoading ? 0.85 : 1,
@ -222,7 +241,7 @@ export default function CoinsDisplay({
</Box> </Box>
); );
if (showTooltip) { if (tooltipEnabled) {
return ( return (
<CustomTooltip <CustomTooltip
title={tooltipText} title={tooltipText}

View File

@ -1,12 +1,39 @@
/* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable react/jsx-props-no-spreading */
import React, { useEffect, useState, useMemo } from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip'; import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip';
// Создаем кастомный стилизованный Tooltip с правильной типизацией const STORAGE_KEY = 'launcher_settings';
const CustomTooltip = styled(({ className, ...props }: TooltipProps) => (
function readDisableTooltip(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
const s = JSON.parse(raw);
return Boolean(s?.disableToolTip);
} catch {
return false;
}
}
const readTooltipPolicy = () => {
try {
const raw = localStorage.getItem('launcher_settings');
if (!raw) return { disableToolTip: false, allowEssentialTooltips: true };
const s = JSON.parse(raw);
return {
disableToolTip: Boolean(s?.disableToolTip),
allowEssentialTooltips: s?.allowEssentialTooltips !== false, // default true
};
} catch {
return { disableToolTip: false, allowEssentialTooltips: true };
}
};
// ВАЖНО: styled-компонент отдельно, чтобы не пересоздавался на каждый рендер
const StyledTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} /> <Tooltip {...props} classes={{ popper: className }} />
))(({ theme }) => ({ ))(() => ({
[`& .${tooltipClasses.tooltip}`]: { [`& .${tooltipClasses.tooltip}`]: {
backgroundColor: 'rgba(0, 0, 0, 0.9)', backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: '#fff', color: '#fff',
@ -72,4 +99,29 @@ const CustomTooltip = styled(({ className, ...props }: TooltipProps) => (
}, },
})); }));
export default CustomTooltip; export type CustomTooltipProps = TooltipProps & {
/**
* Можно принудительно отключить тултип снаружи,
* плюс учитывается настройка disableToolTip из launcher_settings
*/
essential?: boolean;
disabled?: boolean;
};
export default function CustomTooltip(props: CustomTooltipProps) {
const { essential = false, children, ...rest } = props;
const { disableToolTip, allowEssentialTooltips } = useMemo(
() => readTooltipPolicy(),
// важно: чтобы при "Save" пересчитывалось — ты уже диспатчишь settings-updated
// поэтому ниже мы просто прочитаем ещё раз через key в местах использования (или можно слушать event тут)
[],
);
const disabledBySettings = disableToolTip && !(essential && allowEssentialTooltips);
// Если отключено — просто возвращаем children без обёртки Tooltip
if (disabledBySettings) return <>{children}</>;
return <StyledTooltip {...props}>{children}</StyledTooltip>;
}

View File

@ -206,6 +206,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
const logout = () => { const logout = () => {
localStorage.removeItem('launcher_config'); localStorage.removeItem('launcher_config');
navigate('/login'); navigate('/login');
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: false });
}; };
const loadSkin = useCallback(async () => { const loadSkin = useCallback(async () => {

View File

@ -435,7 +435,7 @@ export default function DailyReward({ onClaimed }: Props) {
</Box> </Box>
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<CustomTooltip title="К текущему месяцу"> <CustomTooltip essential title="К текущему месяцу">
<IconButton <IconButton
onClick={goToday} onClick={goToday}
sx={{ sx={{
@ -661,7 +661,7 @@ export default function DailyReward({ onClaimed }: Props) {
</Box> </Box>
<Box sx={{ display: 'flex', gap: '1.2vw', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: '1.2vw', alignItems: 'center' }}>
<CustomTooltip title={subtitle} disableInteractive> <CustomTooltip essential title={subtitle} disableInteractive>
<Box <Box
sx={{ sx={{
display: 'inline-block', display: 'inline-block',
@ -701,7 +701,7 @@ export default function DailyReward({ onClaimed }: Props) {
</Box> </Box>
</CustomTooltip> </CustomTooltip>
<CustomTooltip title="Сбросить выбор на сегодня"> <CustomTooltip essential title="Сбросить выбор на сегодня">
<IconButton <IconButton
onClick={() => setSelected(today)} onClick={() => setSelected(today)}
sx={{ sx={{

View File

@ -96,7 +96,7 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
if (onLoginSuccess) { if (onLoginSuccess) {
onLoginSuccess(config.username); onLoginSuccess(config.username);
} }
await window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: true });
navigate('/'); navigate('/');
} catch (error: any) { } catch (error: any) {
console.log(`ОШИБКА при авторизации: ${error.message}`); console.log(`ОШИБКА при авторизации: ${error.message}`);

View File

@ -29,6 +29,8 @@ type SettingsState = {
autoLaunch: boolean; autoLaunch: boolean;
startInTray: boolean; startInTray: boolean;
closeToTray: boolean; closeToTray: boolean;
disableToolTip: boolean;
allowEssentialTooltips: boolean;
// Game // Game
autoRotateSkinViewer: boolean; autoRotateSkinViewer: boolean;
@ -73,7 +75,7 @@ const GRADIENT =
backgroundColor: 'rgba(10,10,20,0.92)', backgroundColor: 'rgba(10,10,20,0.92)',
border: '2px solid rgba(255,255,255,0.18)', border: '2px solid rgba(255,255,255,0.18)',
boxShadow: '0 0 1.6vw rgba(233,64,205,0.35)', boxShadow: '0 0 1.6vw rgba(233,64,205,0.35)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease', transition: 'transform 0.15s ease, box-shadow 0.15s ease, height 0.3s ease, width 0.3s ease',
'&:before': { display: 'none' }, '&:before': { display: 'none' },
'&:hover, &.Mui-focusVisible': { '&:hover, &.Mui-focusVisible': {
width: '1.95vw', width: '1.95vw',
@ -100,6 +102,8 @@ const defaultSettings: SettingsState = {
startInTray: false, startInTray: false,
autoLaunch: false, autoLaunch: false,
closeToTray: true, closeToTray: true,
disableToolTip: false,
allowEssentialTooltips: true,
autoRotateSkinViewer: true, autoRotateSkinViewer: true,
walkingSpeed: 0.5, walkingSpeed: 0.5,
@ -351,7 +355,7 @@ const Settings = () => {
sx={{ sx={{
px: '2vw', px: '2vw',
pb: '2vw', pb: '2vw',
width: '100%', width: '95%',
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
> >
@ -567,6 +571,20 @@ const Settings = () => {
checked={settings.rememberLastRoute} checked={settings.rememberLastRoute}
onChange={setFlag('rememberLastRoute')} onChange={setFlag('rememberLastRoute')}
/> />
<SettingCheckboxRow
title="Отключить подсказки"
description="Отключить подсказки при наведении на элементы"
checked={settings.disableToolTip}
onChange={setFlag('disableToolTip')}
/>
<SettingCheckboxRow
title="Показывать важные подсказки"
description="Некоторые подсказки нельзя отключить (важные)"
checked={settings.allowEssentialTooltips}
onChange={setFlag('allowEssentialTooltips')}
/>
</Box> </Box>
</Glass> </Glass>
</Box> </Box>

View File

@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export function TrayBridge() {
const navigate = useNavigate();
useEffect(() => {
const onNavigate = (to: unknown) => {
navigate(String(to));
};
const onLogout = () => {
localStorage.removeItem('launcher_config');
navigate('/login');
};
window.electron.ipcRenderer.on('tray-navigate', onNavigate);
window.electron.ipcRenderer.on('tray-logout', onLogout);
return () => {
window.electron.ipcRenderer.removeAllListeners('tray-navigate');
window.electron.ipcRenderer.removeAllListeners('tray-logout');
};
}, [navigate]);
return null;
}