diff --git a/changelog.md b/changelog.md index 763f6d59..22d65524 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,4 @@ -### Breaking Changes - -- 此版本修改了应用的显示名称,macOS用户可能无法自动更新,需要手动删除 `/Applications/mihomo-party.app` - ### New Features - 允许切换订阅卡片显示过期时间还是更新时间 +- 添加悬浮窗功能,可以在设置中开启 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 0c1f0d8d..1b054171 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -22,6 +22,14 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()] }, renderer: { + build: { + rollupOptions: { + input: { + index: resolve('src/renderer/index.html'), + floating: resolve('src/renderer/floating.html') + } + } + }, resolve: { alias: { '@renderer': resolve('src/renderer/src') diff --git a/src/main/core/mihomoApi.ts b/src/main/core/mihomoApi.ts index c3063580..096158dd 100644 --- a/src/main/core/mihomoApi.ts +++ b/src/main/core/mihomoApi.ts @@ -5,6 +5,7 @@ import WebSocket from 'ws' import { tray } from '../resolve/tray' import { calcTraffic } from '../utils/calc' import { getRuntimeConfig } from './factory' +import { floatingWindow } from '../resolve/floatingWindow' let axiosIns: AxiosInstance = null! let mihomoTrafficWs: WebSocket | null = null @@ -202,6 +203,7 @@ const mihomoTraffic = async (): Promise => { `${calcTraffic(json.down)}/s`.padStart(9) ) } + floatingWindow?.webContents.send('mihomoTraffic', json) } catch { // ignore } diff --git a/src/main/index.ts b/src/main/index.ts index 41b0425a..275a149c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { existsSync, writeFileSync } from 'fs' import { exePath, taskDir } from './utils/dirs' import path from 'path' import { startMonitor } from './resolve/trafficMonitor' +import { showFloatingWindow } from './resolve/floatingWindow' let quitTimeout: NodeJS.Timeout | null = null export let mainWindow: BrowserWindow | null = null @@ -149,8 +150,12 @@ app.whenReady().then(async () => { app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) + const { showFloatingWindow: showFloating = false } = await getAppConfig() registerIpcMainHandlers() await createWindow() + if (showFloating) { + showFloatingWindow() + } await createTray() await initShortcut() app.on('activate', function () { @@ -191,7 +196,8 @@ export async function createWindow(): Promise { const { useWindowFrame = false } = await getAppConfig() const mainWindowState = windowStateKeeper({ defaultWidth: 800, - defaultHeight: 600 + defaultHeight: 600, + file: 'window-state.json' }) // https://github.com/electron/electron/issues/16521#issuecomment-582955104 Menu.setApplicationMenu(null) @@ -269,7 +275,6 @@ export async function createWindow(): Promise { shell.openExternal(details.url) return { action: 'deny' } }) - // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { @@ -288,3 +293,9 @@ export function showMainWindow(): void { mainWindow.focusOnWebView() } } + +export function closeMainWindow(): void { + if (mainWindow) { + mainWindow.close() + } +} diff --git a/src/main/resolve/floatingWindow.ts b/src/main/resolve/floatingWindow.ts new file mode 100644 index 00000000..3fd56a93 --- /dev/null +++ b/src/main/resolve/floatingWindow.ts @@ -0,0 +1,75 @@ +import { is } from '@electron-toolkit/utils' +import { BrowserWindow, ipcMain } from 'electron' +import windowStateKeeper from 'electron-window-state' +import { join } from 'path' +import { getAppConfig } from '../config' +import { applyTheme } from './theme' +import { buildContextMenu } from './tray' + +export let floatingWindow: BrowserWindow | null = null + +async function createFloatingWindow(): Promise { + const floatingWindowState = windowStateKeeper({ + file: 'floating-window-state.json' + }) + const { customTheme = 'default.css' } = await getAppConfig() + floatingWindow = new BrowserWindow({ + width: 126, + height: 50, + x: floatingWindowState.x, + y: floatingWindowState.y, + show: false, + frame: false, + alwaysOnTop: true, + resizable: false, + transparent: true, + skipTaskbar: true, + minimizable: false, + maximizable: false, + closable: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + spellcheck: false, + sandbox: false + } + }) + floatingWindowState.manage(floatingWindow) + floatingWindow.on('ready-to-show', () => { + applyTheme(customTheme) + floatingWindow?.show() + }) + floatingWindow.on('moved', () => { + if (floatingWindow) floatingWindowState.saveState(floatingWindow) + }) + ipcMain.on('updateFloatingWindow', () => { + if (floatingWindow) { + floatingWindow?.webContents.send('controledMihomoConfigUpdated') + floatingWindow?.webContents.send('appConfigUpdated') + } + }) + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`) + } else { + floatingWindow.loadFile(join(__dirname, '../renderer/floating.html')) + } +} +export function showFloatingWindow(): void { + if (floatingWindow) { + floatingWindow.show() + } else { + createFloatingWindow() + } +} + +export function closeFloatingWindow(): void { + if (floatingWindow) { + floatingWindow.close() + floatingWindow.destroy() + floatingWindow = null + } +} + +export async function showContextMenu(): Promise { + const menu = await buildContextMenu() + menu.popup() +} diff --git a/src/main/resolve/shortcut.ts b/src/main/resolve/shortcut.ts index 1dad28ae..07c932de 100644 --- a/src/main/resolve/shortcut.ts +++ b/src/main/resolve/shortcut.ts @@ -9,6 +9,7 @@ import { import { triggerSysProxy } from '../sys/sysproxy' import { patchMihomoConfig } from '../core/mihomoApi' import { quitWithoutCore, restartCore } from '../core/manager' +import { closeFloatingWindow, floatingWindow, showFloatingWindow } from './floatingWindow' export async function registerShortcut( oldShortcut: string, @@ -31,6 +32,15 @@ export async function registerShortcut( } }) } + case 'showFloatingWindowShortcut': { + return globalShortcut.register(newShortcut, () => { + if (floatingWindow) { + closeFloatingWindow() + } else { + showFloatingWindow() + } + }) + } case 'triggerSysProxyShortcut': { return globalShortcut.register(newShortcut, async () => { const { @@ -42,10 +52,11 @@ export async function registerShortcut( new Notification({ title: `系统代理已${!enable ? '开启' : '关闭'}` }).show() + mainWindow?.webContents.send('appConfigUpdated') + floatingWindow?.webContents.send('appConfigUpdated') } catch { // ignore } finally { - mainWindow?.webContents.send('appConfigUpdated') ipcMain.emit('updateTrayMenu') } }) @@ -64,10 +75,11 @@ export async function registerShortcut( new Notification({ title: `虚拟网卡已${!enable ? '开启' : '关闭'}` }).show() + mainWindow?.webContents.send('controledMihomoConfigUpdated') + floatingWindow?.webContents.send('appConfigUpdated') } catch { // ignore } finally { - mainWindow?.webContents.send('controledMihomoConfigUpdated') ipcMain.emit('updateTrayMenu') } }) @@ -122,6 +134,7 @@ export async function registerShortcut( export async function initShortcut(): Promise { const { + showFloatingWindowShortcut, showWindowShortcut, triggerSysProxyShortcut, triggerTunShortcut, @@ -138,6 +151,13 @@ export async function initShortcut(): Promise { // ignore } } + if (showFloatingWindowShortcut) { + try { + await registerShortcut('', showFloatingWindowShortcut, 'showFloatingWindowShortcut') + } catch { + // ignore + } + } if (triggerSysProxyShortcut) { try { await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut') diff --git a/src/main/resolve/theme.ts b/src/main/resolve/theme.ts index 0c6b8eb5..e50c29bb 100644 --- a/src/main/resolve/theme.ts +++ b/src/main/resolve/theme.ts @@ -6,8 +6,10 @@ import AdmZip from 'adm-zip' import { getControledMihomoConfig } from '../config' import { existsSync } from 'fs' import { mainWindow } from '..' +import { floatingWindow } from './floatingWindow' -let insertedCSSKey: string | undefined = undefined +let insertedCSSKeyMain: string | undefined = undefined +let insertedCSSKeyFloating: string | undefined = undefined export async function resolveThemes(): Promise<{ key: string; label: string }[]> { const files = await readdir(themesDir()) @@ -67,6 +69,12 @@ export async function writeTheme(theme: string, css: string): Promise { export async function applyTheme(theme: string): Promise { const css = await readTheme(theme) - await mainWindow?.webContents.removeInsertedCSS(insertedCSSKey || '') - insertedCSSKey = await mainWindow?.webContents.insertCSS(css) + await mainWindow?.webContents.removeInsertedCSS(insertedCSSKeyMain || '') + insertedCSSKeyMain = await mainWindow?.webContents.insertCSS(css) + try { + await floatingWindow?.webContents.removeInsertedCSS(insertedCSSKeyFloating || '') + insertedCSSKeyFloating = await floatingWindow?.webContents.insertCSS(css) + } catch { + // ignore + } } diff --git a/src/main/resolve/tray.ts b/src/main/resolve/tray.ts index d5c29669..acab857e 100644 --- a/src/main/resolve/tray.ts +++ b/src/main/resolve/tray.ts @@ -15,15 +15,16 @@ import { mihomoGroups, patchMihomoConfig } from '../core/mihomoApi' -import { mainWindow, showMainWindow } from '..' +import { closeMainWindow, mainWindow, showMainWindow } from '..' import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron' import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs' import { triggerSysProxy } from '../sys/sysproxy' import { quitWithoutCore, restartCore } from '../core/manager' +import { closeFloatingWindow, floatingWindow, showFloatingWindow } from './floatingWindow' export let tray: Tray | null = null -const buildContextMenu = async (): Promise => { +export const buildContextMenu = async (): Promise => { const { mode, tun } = await getControledMihomoConfig() const { sysProxy, @@ -31,6 +32,7 @@ const buildContextMenu = async (): Promise => { autoCloseConnection, proxyInTray = true, triggerSysProxyShortcut = '', + showFloatingWindowShortcut = '', showWindowShortcut = '', triggerTunShortcut = '', ruleModeShortcut = '', @@ -90,6 +92,19 @@ const buildContextMenu = async (): Promise => { showMainWindow() } }, + { + id: 'show-floating', + accelerator: showFloatingWindowShortcut, + label: floatingWindow ? '关闭悬浮窗' : '显示悬浮窗', + type: 'normal', + click: (): void => { + if (floatingWindow) { + closeFloatingWindow() + } else { + showFloatingWindow() + } + } + }, { id: 'rule', label: '规则模式', @@ -140,10 +155,11 @@ const buildContextMenu = async (): Promise => { try { await triggerSysProxy(enable) await patchAppConfig({ sysProxy: { enable } }) + mainWindow?.webContents.send('appConfigUpdated') + floatingWindow?.webContents.send('appConfigUpdated') } catch (e) { // ignore } finally { - mainWindow?.webContents.send('appConfigUpdated') ipcMain.emit('updateTrayMenu') } } @@ -155,14 +171,20 @@ const buildContextMenu = async (): Promise => { checked: tun?.enable ?? false, click: async (item): Promise => { const enable = item.checked - if (enable) { - await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } }) - } else { - await patchControledMihomoConfig({ tun: { enable } }) + try { + if (enable) { + await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } }) + } else { + await patchControledMihomoConfig({ tun: { enable } }) + } + mainWindow?.webContents.send('controledMihomoConfigUpdated') + floatingWindow?.webContents.send('controledMihomoConfigUpdated') + await restartCore() + } catch { + // ignore + } finally { + ipcMain.emit('updateTrayMenu') } - mainWindow?.webContents.send('controledMihomoConfigUpdated') - await restartCore() - ipcMain.emit('updateTrayMenu') } }, ...groupsMenu, @@ -291,7 +313,7 @@ export async function createTray(): Promise { }) tray?.addListener('right-click', async () => { if (mainWindow?.isVisible()) { - mainWindow?.close() + closeMainWindow() } else { showMainWindow() } @@ -303,7 +325,7 @@ export async function createTray(): Promise { if (process.platform === 'win32') { tray?.addListener('click', () => { if (mainWindow?.isVisible()) { - mainWindow?.close() + closeMainWindow() } else { showMainWindow() } @@ -315,7 +337,7 @@ export async function createTray(): Promise { if (process.platform === 'linux') { tray?.addListener('click', () => { if (mainWindow?.isVisible()) { - mainWindow?.close() + closeMainWindow() } else { showMainWindow() } diff --git a/src/main/utils/ipc.ts b/src/main/utils/ipc.ts index 16e49d0c..1a30563f 100644 --- a/src/main/utils/ipc.ts +++ b/src/main/utils/ipc.ts @@ -65,7 +65,7 @@ import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore } from '.. import { getInterfaces } from '../sys/interface' import { copyEnv } from '../resolve/tray' import { registerShortcut } from '../resolve/shortcut' -import { mainWindow } from '..' +import { closeMainWindow, mainWindow, showMainWindow } from '..' import { applyTheme, fetchThemes, @@ -81,6 +81,7 @@ import v8 from 'v8' import { getGistUrl } from '../resolve/gistApi' import { getImageDataURL } from './image' import { startMonitor } from '../resolve/trafficMonitor' +import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow' function ipcErrorWrapper( // eslint-disable-next-line @typescript-eslint/no-explicit-any fn: (...args: any[]) => Promise // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -209,6 +210,11 @@ export function registerIpcMainHandlers(): void { ipcMain.handle('isAlwaysOnTop', () => { return mainWindow?.isAlwaysOnTop() }) + ipcMain.handle('showMainWindow', showMainWindow) + ipcMain.handle('closeMainWindow', closeMainWindow) + ipcMain.handle('showFloatingWindow', showFloatingWindow) + ipcMain.handle('closeFloatingWindow', closeFloatingWindow) + ipcMain.handle('showContextMenu', () => ipcErrorWrapper(showContextMenu)()) ipcMain.handle('openFile', (_e, type, id, ext) => openFile(type, id, ext)) ipcMain.handle('openDevTools', () => { mainWindow?.webContents.openDevTools() diff --git a/src/renderer/floating.html b/src/renderer/floating.html new file mode 100644 index 00000000..5d436bee --- /dev/null +++ b/src/renderer/floating.html @@ -0,0 +1,17 @@ + + + + + Mihomo Party + + + + + +
+ + + diff --git a/src/renderer/src/FloatingApp.tsx b/src/renderer/src/FloatingApp.tsx new file mode 100644 index 00000000..97129d6e --- /dev/null +++ b/src/renderer/src/FloatingApp.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import MihomoIcon from './components/base/mihomo-icon' +import { calcTraffic } from './utils/calc' +import { showContextMenu, showMainWindow } from './utils/ipc' +import { useAppConfig } from './hooks/use-app-config' +import { useControledMihomoConfig } from './hooks/use-controled-mihomo-config' + +const FloatingApp: React.FC = () => { + const { appConfig } = useAppConfig() + const { controledMihomoConfig } = useControledMihomoConfig() + const { sysProxy } = appConfig || {} + const { tun } = controledMihomoConfig || {} + const sysProxyEnabled = sysProxy?.enable + const tunEnabled = tun?.enable + + const [upload, setUpload] = useState(0) + const [download, setDownload] = useState(0) + useEffect(() => { + window.electron.ipcRenderer.on('mihomoTraffic', async (_e, info: IMihomoTrafficInfo) => { + setUpload(info.up) + setDownload(info.down) + }) + return (): void => { + window.electron.ipcRenderer.removeAllListeners('mihomoTraffic') + } + }, []) + return ( +
+
+
+
{ + e.preventDefault() + showContextMenu() + }} + onClick={() => { + showMainWindow() + }} + className={`app-nodrag cursor-pointer floating-thumb ${tunEnabled ? 'bg-secondary' : sysProxyEnabled ? 'bg-primary' : 'bg-default'} hover:opacity-hover rounded-full h-[calc(100vh-14px)] w-[calc(100vh-14px)]`} + > + +
+
+
+
+
+ {calcTraffic(upload)}/s +
+
+
+
+ {calcTraffic(download)}/s +
+
+
+
+
+ ) +} + +export default FloatingApp diff --git a/src/renderer/src/assets/floating.css b/src/renderer/src/assets/floating.css new file mode 100644 index 00000000..c9f1c6d7 --- /dev/null +++ b/src/renderer/src/assets/floating.css @@ -0,0 +1,20 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + background: none !important; + background-color: transparent !important; +} + +.app-nodrag { + -webkit-app-region: none; +} + +.app-drag { + -webkit-app-region: drag; +} + +* { + user-select: none; +} diff --git a/src/renderer/src/components/settings/general-config.tsx b/src/renderer/src/components/settings/general-config.tsx index 99e2160a..808943b4 100644 --- a/src/renderer/src/components/settings/general-config.tsx +++ b/src/renderer/src/components/settings/general-config.tsx @@ -7,6 +7,7 @@ import useSWR from 'swr' import { applyTheme, checkAutoRun, + closeFloatingWindow, copyEnv, disableAutoRun, enableAutoRun, @@ -15,6 +16,7 @@ import { importThemes, relaunchApp, resolveThemes, + showFloatingWindow, startMonitor, writeTheme } from '@renderer/utils/ipc' @@ -37,6 +39,7 @@ const GeneralConfig: React.FC = () => { useDockIcon = true, showTraffic = true, proxyInTray = true, + showFloatingWindow: showFloating = false, useWindowFrame = false, autoQuitWithoutCore = false, autoQuitWithoutCoreDelay = 60, @@ -175,6 +178,20 @@ const GeneralConfig: React.FC = () => { PowerShell + + { + await patchAppConfig({ showFloatingWindow: v }) + if (v) { + showFloatingWindow() + } else { + closeFloatingWindow() + } + }} + /> + {platform !== 'linux' && ( <> diff --git a/src/renderer/src/components/settings/shortcut-config.tsx b/src/renderer/src/components/settings/shortcut-config.tsx index a90f95b4..25f4f9be 100644 --- a/src/renderer/src/components/settings/shortcut-config.tsx +++ b/src/renderer/src/components/settings/shortcut-config.tsx @@ -43,6 +43,7 @@ const ShortcutConfig: React.FC = () => { const { appConfig, patchAppConfig } = useAppConfig() const { showWindowShortcut = '', + showFloatingWindowShortcut = '', triggerSysProxyShortcut = '', triggerTunShortcut = '', ruleModeShortcut = '', @@ -63,6 +64,15 @@ const ShortcutConfig: React.FC = () => { /> + +
+ +
+
{ try { await triggerSysProxy(enable) await patchAppConfig({ sysProxy: { enable } }) + window.electron.ipcRenderer.send('updateFloatingWindow') window.electron.ipcRenderer.send('updateTrayMenu') } catch (e) { alert(e) diff --git a/src/renderer/src/components/sider/tun-switcher.tsx b/src/renderer/src/components/sider/tun-switcher.tsx index 64ddf082..dbb81437 100644 --- a/src/renderer/src/components/sider/tun-switcher.tsx +++ b/src/renderer/src/components/sider/tun-switcher.tsx @@ -51,6 +51,7 @@ const TunSwitcher: React.FC = () => { await patchControledMihomoConfig({ tun: { enable } }) } await restartCore() + window.electron.ipcRenderer.send('updateFloatingWindow') window.electron.ipcRenderer.send('updateTrayMenu') } diff --git a/src/renderer/src/floating.tsx b/src/renderer/src/floating.tsx new file mode 100644 index 00000000..a2dca4c5 --- /dev/null +++ b/src/renderer/src/floating.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { ThemeProvider as NextThemesProvider } from 'next-themes' +import { NextUIProvider } from '@nextui-org/react' +import '@renderer/assets/floating.css' +import FloatingApp from '@renderer/FloatingApp' +import BaseErrorBoundary from './components/base/base-error-boundary' +import { AppConfigProvider } from './hooks/use-app-config' +import { ControledMihomoConfigProvider } from './hooks/use-controled-mihomo-config' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + +) diff --git a/src/renderer/src/utils/ipc.ts b/src/renderer/src/utils/ipc.ts index 5ef85696..23a0df92 100644 --- a/src/renderer/src/utils/ipc.ts +++ b/src/renderer/src/utils/ipc.ts @@ -327,6 +327,26 @@ export async function subStoreCollections(): Promise { return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('subStoreCollections')) } +export async function showMainWindow(): Promise { + return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showMainWindow')) +} + +export async function closeMainWindow(): Promise { + return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('closeMainWindow')) +} + +export async function showFloatingWindow(): Promise { + return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showFloatingWindow')) +} + +export async function closeFloatingWindow(): Promise { + return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('closeFloatingWindow')) +} + +export async function showContextMenu(): Promise { + return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showContextMenu')) +} + export async function openFile( type: 'profile' | 'override', id: string, diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index 198c1609..5f80e522 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -212,6 +212,7 @@ interface IAppConfig { proxyCols: 'auto' | '1' | '2' | '3' | '4' connectionDirection: 'asc' | 'desc' connectionOrderBy: 'time' | 'upload' | 'download' | 'uploadSpeed' | 'downloadSpeed' + showFloatingWindow?: boolean connectionCardStatus?: CardStatus dnsCardStatus?: CardStatus logCardStatus?: CardStatus @@ -262,6 +263,7 @@ interface IAppConfig { useNameserverPolicy: boolean nameserverPolicy: { [key: string]: string | string[] } showWindowShortcut?: string + showFloatingWindowShortcut?: string triggerSysProxyShortcut?: string triggerTunShortcut?: string ruleModeShortcut?: string