support floating window

This commit is contained in:
pompurin404 2024-10-05 19:36:52 +08:00
parent 0d9e28f8d1
commit 1b523c94f5
No known key found for this signature in database
19 changed files with 356 additions and 25 deletions

View File

@ -1,7 +1,8 @@
### Breaking Changes
- 此版本修改了应用的显示名称macOS用户可能无法自动更新需要手动删除 `/Applications/mihomo-party.app`
### New Features
- 允许切换订阅卡片显示过期时间还是更新时间
- 添加悬浮窗功能,可以在设置中开启
### Bug Fixes
- 修复某些 Windows 管理员权限无法正常启动的问题

View File

@ -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')

View File

@ -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<void> => {
`${calcTraffic(json.down)}/s`.padStart(9)
)
}
floatingWindow?.webContents.send('mihomoTraffic', json)
} catch {
// ignore
}

View File

@ -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<void> {
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<void> {
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()
}
}

View File

@ -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<void> {
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<void> {
const menu = await buildContextMenu()
menu.popup()
}

View File

@ -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,17 @@ export async function registerShortcut(
}
})
}
case 'showFloatingWindowShortcut': {
return globalShortcut.register(newShortcut, async () => {
if (floatingWindow) {
await patchAppConfig({ showFloatingWindow: false })
closeFloatingWindow()
} else {
await patchAppConfig({ showFloatingWindow: true })
showFloatingWindow()
}
})
}
case 'triggerSysProxyShortcut': {
return globalShortcut.register(newShortcut, async () => {
const {
@ -42,10 +54,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 +77,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 +136,7 @@ export async function registerShortcut(
export async function initShortcut(): Promise<void> {
const {
showFloatingWindowShortcut,
showWindowShortcut,
triggerSysProxyShortcut,
triggerTunShortcut,
@ -138,6 +153,13 @@ export async function initShortcut(): Promise<void> {
// ignore
}
}
if (showFloatingWindowShortcut) {
try {
await registerShortcut('', showFloatingWindowShortcut, 'showFloatingWindowShortcut')
} catch {
// ignore
}
}
if (triggerSysProxyShortcut) {
try {
await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut')

View File

@ -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<void> {
export async function applyTheme(theme: string): Promise<void> {
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
}
}

View File

@ -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<Menu> => {
export const buildContextMenu = async (): Promise<Menu> => {
const { mode, tun } = await getControledMihomoConfig()
const {
sysProxy,
@ -31,6 +32,7 @@ const buildContextMenu = async (): Promise<Menu> => {
autoCloseConnection,
proxyInTray = true,
triggerSysProxyShortcut = '',
showFloatingWindowShortcut = '',
showWindowShortcut = '',
triggerTunShortcut = '',
ruleModeShortcut = '',
@ -90,6 +92,21 @@ const buildContextMenu = async (): Promise<Menu> => {
showMainWindow()
}
},
{
id: 'show-floating',
accelerator: showFloatingWindowShortcut,
label: floatingWindow?.isVisible() ? '关闭悬浮窗' : '显示悬浮窗',
type: 'normal',
click: async (): Promise<void> => {
if (floatingWindow) {
await patchAppConfig({ showFloatingWindow: false })
closeFloatingWindow()
} else {
await patchAppConfig({ showFloatingWindow: true })
showFloatingWindow()
}
}
},
{
id: 'rule',
label: '规则模式',
@ -140,10 +157,11 @@ const buildContextMenu = async (): Promise<Menu> => {
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 +173,20 @@ const buildContextMenu = async (): Promise<Menu> => {
checked: tun?.enable ?? false,
click: async (item): Promise<void> => {
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 +315,7 @@ export async function createTray(): Promise<void> {
})
tray?.addListener('right-click', async () => {
if (mainWindow?.isVisible()) {
mainWindow?.close()
closeMainWindow()
} else {
showMainWindow()
}
@ -303,7 +327,7 @@ export async function createTray(): Promise<void> {
if (process.platform === 'win32') {
tray?.addListener('click', () => {
if (mainWindow?.isVisible()) {
mainWindow?.close()
closeMainWindow()
} else {
showMainWindow()
}
@ -315,7 +339,7 @@ export async function createTray(): Promise<void> {
if (process.platform === 'linux') {
tray?.addListener('click', () => {
if (mainWindow?.isVisible()) {
mainWindow?.close()
closeMainWindow()
} else {
showMainWindow()
}

View File

@ -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<T>( // eslint-disable-next-line @typescript-eslint/no-explicit-any
fn: (...args: any[]) => Promise<T> // 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()

View File

@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" lang="zh" />
<title>Mihomo Party</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; frame-src http://127.0.0.1:*;"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/floating.tsx"></script>
</body>
</html>

View File

@ -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 (
<div className="app-drag p-[4px] h-[100vh]">
<div className="floating-bg drop-shadow-md flex rounded-[calc(calc(100vh-8px)/2)] bg-content1 h-[calc(100vh-8px)] w-[calc(100vw-8px)]">
<div className="flex justify-center items-center h-full w-[calc(100vh-8px)]">
<div
onContextMenu={(e) => {
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)]`}
>
<MihomoIcon className="floating-icon text-primary-foreground h-full leading-full text-[22px] mx-auto" />
</div>
</div>
<div className="flex flex-col justify-center w-[calc(100%-42px)]">
<div className="flex justify-end">
<div className="floating-text whitespace-nowrap overflow-hidden text-[12px] mr-[10px] font-bold">
{calcTraffic(upload)}/s
</div>
</div>
<div className="w-full flex justify-end">
<div className="floating-text whitespace-nowrap overflow-hidden text-[12px] mr-[10px] font-bold">
{calcTraffic(download)}/s
</div>
</div>
</div>
</div>
</div>
)
}
export default FloatingApp

View File

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

View File

@ -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 = () => {
<SelectItem key="powershell">PowerShell</SelectItem>
</Select>
</SettingItem>
<SettingItem title="显示悬浮窗" divider>
<Switch
size="sm"
isSelected={showFloating}
onValueChange={async (v) => {
await patchAppConfig({ showFloatingWindow: v })
if (v) {
showFloatingWindow()
} else {
closeFloatingWindow()
}
}}
/>
</SettingItem>
{platform !== 'linux' && (
<>
<SettingItem title="托盘菜单显示节点信息" divider>

View File

@ -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 = () => {
/>
</div>
</SettingItem>
<SettingItem title="打开/关闭悬浮窗" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={showFloatingWindowShortcut}
patchAppConfig={patchAppConfig}
action="showFloatingWindowShortcut"
/>
</div>
</SettingItem>
<SettingItem title="打开/关闭系统代理" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput

View File

@ -29,6 +29,7 @@ const SysproxySwitcher: React.FC = () => {
try {
await triggerSysProxy(enable)
await patchAppConfig({ sysProxy: { enable } })
window.electron.ipcRenderer.send('updateFloatingWindow')
window.electron.ipcRenderer.send('updateTrayMenu')
} catch (e) {
alert(e)

View File

@ -51,6 +51,7 @@ const TunSwitcher: React.FC = () => {
await patchControledMihomoConfig({ tun: { enable } })
}
await restartCore()
window.electron.ipcRenderer.send('updateFloatingWindow')
window.electron.ipcRenderer.send('updateTrayMenu')
}

View File

@ -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(
<React.StrictMode>
<NextUIProvider>
<NextThemesProvider attribute="class" enableSystem defaultTheme="dark">
<BaseErrorBoundary>
<AppConfigProvider>
<ControledMihomoConfigProvider>
<FloatingApp />
</ControledMihomoConfigProvider>
</AppConfigProvider>
</BaseErrorBoundary>
</NextThemesProvider>
</NextUIProvider>
</React.StrictMode>
)

View File

@ -327,6 +327,26 @@ export async function subStoreCollections(): Promise<ISubStoreSub[]> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('subStoreCollections'))
}
export async function showMainWindow(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showMainWindow'))
}
export async function closeMainWindow(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('closeMainWindow'))
}
export async function showFloatingWindow(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showFloatingWindow'))
}
export async function closeFloatingWindow(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('closeFloatingWindow'))
}
export async function showContextMenu(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showContextMenu'))
}
export async function openFile(
type: 'profile' | 'override',
id: string,

View File

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