support global shortcut

This commit is contained in:
pompurin404 2024-08-20 23:00:31 +08:00
parent 1b8205cb18
commit c2b839b7b9
No known key found for this signature in database
12 changed files with 972 additions and 478 deletions

View File

@ -10,6 +10,7 @@ import icon from '../../resources/icon.png?asset'
import { createTray } from './resolve/tray'
import { init } from './utils/init'
import { join } from 'path'
import { initShortcut } from './resolve/shortcut'
export let mainWindow: BrowserWindow | null = null
@ -69,6 +70,7 @@ app.whenReady().then(async () => {
optimizer.watchWindowShortcuts(window)
})
registerIpcMainHandlers()
await initShortcut()
createWindow()
await createTray()
app.on('activate', function () {

View File

@ -0,0 +1,165 @@
import { app, globalShortcut, ipcMain } from 'electron'
import { mainWindow, showMainWindow } from '..'
import {
getAppConfig,
getControledMihomoConfig,
patchAppConfig,
patchControledMihomoConfig
} from '../config'
import { triggerSysProxy } from '../sys/sysproxy'
import { patchMihomoConfig } from '../core/mihomoApi'
export async function registerShortcut(
oldShortcut: string,
newShortcut: string,
action: string
): Promise<boolean> {
if (oldShortcut !== '') {
globalShortcut.unregister(oldShortcut)
}
if (newShortcut === '') {
return true
}
switch (action) {
case 'showWindowShortcut': {
return globalShortcut.register(newShortcut, () => {
if (mainWindow?.isVisible()) {
mainWindow?.close()
} else {
showMainWindow()
}
})
}
case 'triggerSysProxyShortcut': {
return globalShortcut.register(newShortcut, async () => {
const {
sysProxy: { enable }
} = await getAppConfig()
try {
await triggerSysProxy(!enable)
await patchAppConfig({ sysProxy: { enable: !enable } })
} catch {
// ignore
} finally {
mainWindow?.webContents.send('appConfigUpdated')
ipcMain.emit('updateTrayMenu')
}
})
}
case 'triggerTunShortcut': {
return globalShortcut.register(newShortcut, async () => {
const { tun } = await getControledMihomoConfig()
const enable = tun?.enable ?? false
await patchControledMihomoConfig({ tun: { enable: !enable } })
await patchMihomoConfig({ tun: { enable: !enable } })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
})
}
case 'ruleModeShortcut': {
return globalShortcut.register(newShortcut, async () => {
await patchControledMihomoConfig({ mode: 'rule' })
await patchMihomoConfig({ mode: 'rule' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
})
}
case 'globalModeShortcut': {
return globalShortcut.register(newShortcut, async () => {
await patchControledMihomoConfig({ mode: 'global' })
await patchMihomoConfig({ mode: 'global' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
})
}
case 'directModeShortcut': {
return globalShortcut.register(newShortcut, async () => {
await patchControledMihomoConfig({ mode: 'direct' })
await patchMihomoConfig({ mode: 'direct' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
})
}
case 'restartAppShortcut': {
return globalShortcut.register(newShortcut, () => {
app.relaunch()
app.quit()
})
}
case 'quitAppShortcut': {
return globalShortcut.register(newShortcut, () => {
app.quit()
})
}
}
throw new Error('Unknown action')
}
export async function initShortcut(): Promise<void> {
const {
showWindowShortcut,
triggerSysProxyShortcut,
triggerTunShortcut,
ruleModeShortcut,
globalModeShortcut,
directModeShortcut,
restartAppShortcut,
quitAppShortcut
} = await getAppConfig()
if (showWindowShortcut) {
try {
await registerShortcut('', showWindowShortcut, 'showWindowShortcut')
} catch {
// ignore
}
}
if (triggerSysProxyShortcut) {
try {
await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut')
} catch {
// ignore
}
}
if (triggerTunShortcut) {
try {
await registerShortcut('', triggerTunShortcut, 'triggerTunShortcut')
} catch {
// ignore
}
}
if (ruleModeShortcut) {
try {
await registerShortcut('', ruleModeShortcut, 'ruleModeShortcut')
} catch {
// ignore
}
}
if (globalModeShortcut) {
try {
await registerShortcut('', globalModeShortcut, 'globalModeShortcut')
} catch {
// ignore
}
}
if (directModeShortcut) {
try {
await registerShortcut('', directModeShortcut, 'directModeShortcut')
} catch {
// ignore
}
}
if (restartAppShortcut) {
try {
await registerShortcut('', restartAppShortcut, 'restartAppShortcut')
} catch {
// ignore
}
}
if (quitAppShortcut) {
try {
await registerShortcut('', quitAppShortcut, 'quitAppShortcut')
} catch {
// ignore
}
}
}

View File

@ -22,7 +22,19 @@ export let tray: Tray | null = null
const buildContextMenu = async (): Promise<Menu> => {
const { mode, tun } = await getControledMihomoConfig()
const { sysProxy, autoCloseConnection, proxyInTray = true } = await getAppConfig()
const {
sysProxy,
autoCloseConnection,
proxyInTray = true,
triggerSysProxyShortcut = '',
showWindowShortcut = '',
triggerTunShortcut = '',
ruleModeShortcut = '',
globalModeShortcut = '',
directModeShortcut = '',
restartAppShortcut = '',
quitAppShortcut = ''
} = await getAppConfig()
let groupsMenu: Electron.MenuItemConstructorOptions[] = []
if (proxyInTray && process.platform !== 'linux') {
try {
@ -66,6 +78,7 @@ const buildContextMenu = async (): Promise<Menu> => {
const contextMenu = [
{
id: 'show',
accelerator: showWindowShortcut,
label: '显示窗口',
type: 'normal',
click: (): void => {
@ -75,6 +88,7 @@ const buildContextMenu = async (): Promise<Menu> => {
{
id: 'rule',
label: '规则模式',
accelerator: ruleModeShortcut,
type: 'radio',
checked: mode === 'rule',
click: async (): Promise<void> => {
@ -87,6 +101,7 @@ const buildContextMenu = async (): Promise<Menu> => {
{
id: 'global',
label: '全局模式',
accelerator: globalModeShortcut,
type: 'radio',
checked: mode === 'global',
click: async (): Promise<void> => {
@ -99,6 +114,7 @@ const buildContextMenu = async (): Promise<Menu> => {
{
id: 'direct',
label: '直连模式',
accelerator: directModeShortcut,
type: 'radio',
checked: mode === 'direct',
click: async (): Promise<void> => {
@ -112,14 +128,15 @@ const buildContextMenu = async (): Promise<Menu> => {
{
type: 'checkbox',
label: '系统代理',
accelerator: triggerSysProxyShortcut,
checked: sysProxy.enable,
click: async (item): Promise<void> => {
const enable = item.checked
try {
await triggerSysProxy(enable)
await patchAppConfig({ sysProxy: { enable } })
triggerSysProxy(enable)
} catch (e) {
await patchAppConfig({ sysProxy: { enable: !enable } })
// ignore
} finally {
mainWindow?.webContents.send('appConfigUpdated')
ipcMain.emit('updateTrayMenu')
@ -129,6 +146,7 @@ const buildContextMenu = async (): Promise<Menu> => {
{
type: 'checkbox',
label: '虚拟网卡',
accelerator: triggerTunShortcut,
checked: tun?.enable ?? false,
click: async (item): Promise<void> => {
const enable = item.checked
@ -181,12 +199,19 @@ const buildContextMenu = async (): Promise<Menu> => {
id: 'restart',
label: '重启应用',
type: 'normal',
accelerator: restartAppShortcut,
click: (): void => {
app.relaunch()
app.quit()
}
},
{ id: 'quit', label: '退出应用', type: 'normal', click: (): void => app.quit() }
{
id: 'quit',
label: '退出应用',
type: 'normal',
accelerator: quitAppShortcut,
click: (): void => app.quit()
}
] as Electron.MenuItemConstructorOptions[]
return Menu.buildFromTemplate(contextMenu)
}

View File

@ -54,6 +54,7 @@ import { isPortable, setPortable } from './dirs'
import { listWebdavBackups, webdavBackup, webdavRestore } from '../resolve/backup'
import { getInterfaces } from '../sys/interface'
import { copyEnv } from '../resolve/tray'
import { registerShortcut } from '../resolve/shortcut'
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
@ -149,6 +150,9 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('webdavBackup', ipcErrorWrapper(webdavBackup))
ipcMain.handle('webdavRestore', (_e, filename) => ipcErrorWrapper(webdavRestore)(filename))
ipcMain.handle('listWebdavBackups', ipcErrorWrapper(listWebdavBackups))
ipcMain.handle('registerShortcut', (_e, oldShortcut, newShortcut, action) =>
ipcErrorWrapper(registerShortcut)(oldShortcut, newShortcut, action)
)
ipcMain.handle('setNativeTheme', (_e, theme) => {
setNativeTheme(theme)
})

View File

@ -0,0 +1,63 @@
import { Button } from '@nextui-org/react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { checkUpdate, quitApp } from '@renderer/utils/ipc'
import { useState } from 'react'
import UpdaterModal from '../updater/updater-modal'
import { version } from '@renderer/utils/init'
const Actions: React.FC = () => {
const [newVersion, setNewVersion] = useState('')
const [changelog, setChangelog] = useState('')
const [openUpdate, setOpenUpdate] = useState(false)
const [checkingUpdate, setCheckingUpdate] = useState(false)
return (
<>
{openUpdate && (
<UpdaterModal
onClose={() => setOpenUpdate(false)}
version={newVersion}
changelog={changelog}
/>
)}
<SettingCard>
<SettingItem title="检查更新" divider>
<Button
size="sm"
isLoading={checkingUpdate}
onPress={async () => {
try {
setCheckingUpdate(true)
const version = await checkUpdate()
if (version) {
setNewVersion(version.version)
setChangelog(version.changelog)
setOpenUpdate(true)
} else {
new window.Notification('当前已是最新版本', { body: '无需更新' })
}
} catch (e) {
alert(e)
} finally {
setCheckingUpdate(false)
}
}}
>
</Button>
</SettingItem>
<SettingItem title="退出应用" divider>
<Button size="sm" onPress={quitApp}>
退
</Button>
</SettingItem>
<SettingItem title="应用版本">
<div>v{version}</div>
</SettingItem>
</SettingCard>
</>
)
}
export default Actions

View File

@ -0,0 +1,222 @@
import React, { Key } from 'react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { Button, Select, SelectItem, Switch, Tab, Tabs } from '@nextui-org/react'
import { BiCopy } from 'react-icons/bi'
import useSWR from 'swr'
import {
checkAutoRun,
copyEnv,
disableAutoRun,
enableAutoRun,
isPortable,
restartCore,
setNativeTheme,
setPortable
} from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { platform } from '@renderer/utils/init'
import { useTheme } from 'next-themes'
const GeneralConfig: React.FC = () => {
const { data: enable, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
const { data: portable, mutate: mutatePortable } = useSWR('isPortable', isPortable)
const { appConfig, patchAppConfig } = useAppConfig()
const { setTheme } = useTheme()
const {
silentStart = false,
useDockIcon = true,
showTraffic = true,
proxyInTray = true,
envType = platform === 'win32' ? 'powershell' : 'bash',
autoCheckUpdate,
appTheme = 'system'
} = appConfig || {}
const onThemeChange = (key: Key, type: 'theme' | 'color'): void => {
const [theme, color] = appTheme.split('-')
if (type === 'theme') {
let themeStr = key.toString()
if (key !== 'system') {
if (color) {
themeStr += `-${color}`
}
}
if (themeStr.includes('light')) {
setNativeTheme('light')
} else if (themeStr === 'system') {
setNativeTheme('system')
} else {
setNativeTheme('dark')
}
setTheme(themeStr)
patchAppConfig({ appTheme: themeStr as AppTheme })
} else {
let themeStr = theme
if (theme !== 'system') {
if (key !== 'blue') {
themeStr += `-${key}`
}
setTheme(themeStr)
patchAppConfig({ appTheme: themeStr as AppTheme })
}
}
}
return (
<SettingCard>
<SettingItem title="开机自启" divider>
<Switch
size="sm"
isSelected={enable}
onValueChange={async (v) => {
try {
if (v) {
await enableAutoRun()
} else {
await disableAutoRun()
}
} catch (e) {
alert(e)
} finally {
mutateEnable()
}
}}
/>
</SettingItem>
<SettingItem title="自动检查更新" divider>
<Switch
size="sm"
isSelected={autoCheckUpdate}
onValueChange={(v) => {
patchAppConfig({ autoCheckUpdate: v })
}}
/>
</SettingItem>
<SettingItem title="静默启动" divider>
<Switch
size="sm"
isSelected={silentStart}
onValueChange={(v) => {
patchAppConfig({ silentStart: v })
}}
/>
</SettingItem>
<SettingItem
title="复制环境变量类型"
actions={
<Button isIconOnly size="sm" className="ml-2" variant="light" onPress={copyEnv}>
<BiCopy className="text-lg" />
</Button>
}
divider
>
<Select
className="w-[150px]"
size="sm"
selectedKeys={new Set([envType])}
onSelectionChange={async (v) => {
try {
await patchAppConfig({ envType: v.currentKey as 'bash' | 'cmd' | 'powershell' })
} catch (e) {
alert(e)
}
}}
>
<SelectItem key="bash">Bash</SelectItem>
<SelectItem key="cmd">CMD</SelectItem>
<SelectItem key="powershell">PowerShell</SelectItem>
</Select>
</SettingItem>
{platform !== 'linux' && (
<SettingItem title="托盘菜单显示节点信息" divider>
<Switch
size="sm"
isSelected={proxyInTray}
onValueChange={async (v) => {
await patchAppConfig({ proxyInTray: v })
}}
/>
</SettingItem>
)}
{platform === 'darwin' && (
<>
<SettingItem title="显示Dock图标" divider>
<Switch
size="sm"
isSelected={useDockIcon}
onValueChange={async (v) => {
await patchAppConfig({ useDockIcon: v })
}}
/>
</SettingItem>
<SettingItem title="显示网速信息" divider>
<Switch
size="sm"
isSelected={showTraffic}
onValueChange={async (v) => {
await patchAppConfig({ showTraffic: v })
await restartCore()
}}
/>
</SettingItem>
</>
)}
{platform === 'win32' && (
<SettingItem title="数据存储路径" divider>
<Select
className="w-[150px]"
size="sm"
selectedKeys={new Set([portable ? 'portable' : 'data'])}
onSelectionChange={async (v) => {
try {
await setPortable(v.currentKey === 'portable')
} catch (e) {
alert(e)
} finally {
mutatePortable()
}
}}
>
<SelectItem key="data">AppData</SelectItem>
<SelectItem key="portable"></SelectItem>
</Select>
</SettingItem>
)}
<SettingItem title="背景色" divider={appTheme !== 'system'}>
<Tabs
size="sm"
color="primary"
selectedKey={appTheme.split('-')[0]}
onSelectionChange={(key) => {
onThemeChange(key, 'theme')
}}
>
<Tab key="system" title="自动" />
<Tab key="dark" title="深色" />
<Tab key="gray" title="灰色" />
<Tab key="light" title="浅色" />
</Tabs>
</SettingItem>
{appTheme !== 'system' && (
<SettingItem title="主题色">
<Tabs
size="sm"
color="primary"
selectedKey={appTheme.split('-')[1] || 'blue'}
onSelectionChange={(key) => {
onThemeChange(key, 'color')
}}
>
<Tab key="blue" title="蓝色" />
<Tab key="pink" title="粉色" />
<Tab key="green" title="绿色" />
</Tabs>
</SettingItem>
)}
</SettingCard>
)
}
export default GeneralConfig

View File

@ -0,0 +1,98 @@
import React, { useState } from 'react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { Input, Switch } from '@nextui-org/react'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import debounce from '@renderer/utils/debounce'
import { patchControledMihomoConfig } from '@renderer/utils/ipc'
const MihomoConfig: React.FC = () => {
const { appConfig, patchAppConfig } = useAppConfig()
const {
controlDns = true,
controlSniff = true,
delayTestTimeout,
autoCloseConnection = true,
delayTestUrl,
userAgent
} = appConfig || {}
const [url, setUrl] = useState(delayTestUrl)
const setUrlDebounce = debounce((v: string) => {
patchAppConfig({ delayTestUrl: v })
}, 500)
const [ua, setUa] = useState(userAgent)
const setUaDebounce = debounce((v: string) => {
patchAppConfig({ userAgent: v })
}, 500)
return (
<SettingCard>
<SettingItem title="订阅拉取 UA" divider>
<Input
size="sm"
className="w-[60%]"
value={ua}
placeholder="默认 clash-meta"
onValueChange={(v) => {
setUa(v)
setUaDebounce(v)
}}
></Input>
</SettingItem>
<SettingItem title="延迟测试地址" divider>
<Input
size="sm"
className="w-[60%]"
value={url}
placeholder="默认https://www.gstatic.com/generate_204"
onValueChange={(v) => {
setUrl(v)
setUrlDebounce(v)
}}
></Input>
</SettingItem>
<SettingItem title="延迟测试超时时间" divider>
<Input
type="number"
size="sm"
className="w-[60%]"
value={delayTestTimeout?.toString()}
placeholder="默认5000"
onValueChange={(v) => {
patchAppConfig({ delayTestTimeout: parseInt(v) })
}}
/>
</SettingItem>
<SettingItem title="接管DNS设置" divider>
<Switch
size="sm"
isSelected={controlDns}
onValueChange={async (v) => {
await patchAppConfig({ controlDns: v })
await patchControledMihomoConfig({})
}}
/>
</SettingItem>
<SettingItem title="接管域名嗅探设置" divider>
<Switch
size="sm"
isSelected={controlSniff}
onValueChange={async (v) => {
await patchAppConfig({ controlSniff: v })
await patchControledMihomoConfig({})
}}
/>
</SettingItem>
<SettingItem title="自动断开连接">
<Switch
size="sm"
isSelected={autoCloseConnection}
onValueChange={(v) => {
patchAppConfig({ autoCloseConnection: v })
}}
/>
</SettingItem>
</SettingCard>
)
}
export default MihomoConfig

View File

@ -0,0 +1,227 @@
import { Button, Input } from '@nextui-org/react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import React, { KeyboardEvent, useState } from 'react'
import { platform } from '@renderer/utils/init'
import { registerShortcut } from '@renderer/utils/ipc'
const keyMap = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Equal: '=',
Minus: '-',
Plus: 'PLUS',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
Backspace: 'Backspace',
CapsLock: 'Capslock',
ContextMenu: 'Contextmenu',
Space: 'Space',
Tab: 'Tab',
Convert: 'Convert',
Delete: 'Delete',
End: 'End',
Help: 'Help',
Home: 'Home',
PageDown: 'Pagedown',
PageUp: 'Pageup',
Escape: 'Esc',
PrintScreen: 'Printscreen',
ScrollLock: 'Scrolllock',
Pause: 'Pause',
Insert: 'Insert',
Suspend: 'Suspend'
}
const ShortcutConfig: React.FC = () => {
const { appConfig, patchAppConfig } = useAppConfig()
const {
showWindowShortcut = '',
triggerSysProxyShortcut = '',
triggerTunShortcut = '',
ruleModeShortcut = '',
globalModeShortcut = '',
directModeShortcut = '',
restartAppShortcut = '',
quitAppShortcut = ''
} = appConfig || {}
return (
<SettingCard>
<SettingItem title="打开/关闭窗口" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={showWindowShortcut}
patchAppConfig={patchAppConfig}
action="showWindowShortcut"
/>
</div>
</SettingItem>
<SettingItem title="打开/关闭系统代理" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={triggerSysProxyShortcut}
patchAppConfig={patchAppConfig}
action="triggerSysProxyShortcut"
/>
</div>
</SettingItem>
<SettingItem title="打开/关闭虚拟网卡" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={triggerTunShortcut}
patchAppConfig={patchAppConfig}
action="triggerTunShortcut"
/>
</div>
</SettingItem>
<SettingItem title="切换规则模式" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={ruleModeShortcut}
patchAppConfig={patchAppConfig}
action="ruleModeShortcut"
/>
</div>
</SettingItem>
<SettingItem title="切换全局模式" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={globalModeShortcut}
patchAppConfig={patchAppConfig}
action="globalModeShortcut"
/>
</div>
</SettingItem>
<SettingItem title="切换直连模式" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={directModeShortcut}
patchAppConfig={patchAppConfig}
action="directModeShortcut"
/>
</div>
</SettingItem>
<SettingItem title="重启应用" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={restartAppShortcut}
patchAppConfig={patchAppConfig}
action="restartAppShortcut"
/>
</div>
</SettingItem>
<SettingItem title="退出应用">
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={quitAppShortcut}
patchAppConfig={patchAppConfig}
action="quitAppShortcut"
/>
</div>
</SettingItem>
</SettingCard>
)
}
const ShortcutInput: React.FC<{
value: string
action: string
patchAppConfig: (value: Partial<IAppConfig>) => Promise<void>
}> = (props) => {
const { value, action, patchAppConfig } = props
const [inputValue, setInputValue] = useState(value)
const parseShortcut = (
event: KeyboardEvent,
setKey: { (value: React.SetStateAction<string>): void; (arg0: string): void }
): void => {
event.preventDefault()
let code = event.code
const key = event.key
if (code === 'Backspace') {
setKey('')
} else {
let newValue = ''
if (event.ctrlKey) {
newValue = 'Ctrl'
}
if (event.shiftKey) {
newValue = `${newValue}${newValue.length > 0 ? '+' : ''}Shift`
}
if (event.metaKey) {
newValue = `${newValue}${newValue.length > 0 ? '+' : ''}${platform === 'darwin' ? 'Command' : 'Super'}`
}
if (event.altKey) {
newValue = `${newValue}${newValue.length > 0 ? '+' : ''}Alt`
}
if (code.startsWith('Key')) {
code = code.substring(3)
} else if (code.startsWith('Digit')) {
code = code.substring(5)
} else if (code.startsWith('Arrow')) {
code = code.substring(5)
} else if (key.startsWith('Arrow')) {
code = key.substring(5)
} else if (code.startsWith('Intl')) {
code = code.substring(4)
} else if (code.startsWith('Numpad')) {
if (key.length === 1) {
code = 'Num' + code.substring(6)
} else {
code = key
}
} else if (/F\d+/.test(code)) {
// f1-f12
} else if (keyMap[code] !== undefined) {
code = keyMap[code]
} else {
code = ''
}
setKey(`${newValue}${newValue.length > 0 && code.length > 0 ? '+' : ''}${code}`)
}
}
return (
<>
{inputValue !== value && (
<Button
color="primary"
className="mr-2"
size="sm"
onPress={async () => {
try {
if (await registerShortcut(value, inputValue, action)) {
await patchAppConfig({ [action]: inputValue })
window.electron.ipcRenderer.send('updateTrayMenu')
} else {
alert('快捷键注册失败')
}
} catch (e) {
alert(`快捷键注册失败: ${e}`)
}
}}
>
</Button>
)}
<Input
placeholder="点击输入快捷键"
onKeyDown={(e: KeyboardEvent): void => {
parseShortcut(e, setInputValue)
}}
size="sm"
onClear={() => setInputValue('')}
value={inputValue}
className="w-[calc(100%-72px)] pr-0"
/>
</>
)
}
export default ShortcutConfig

View File

@ -0,0 +1,105 @@
import React, { useState } from 'react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { Button, Input } from '@nextui-org/react'
import { listWebdavBackups, webdavBackup } from '@renderer/utils/ipc'
import WebdavRestoreModal from './webdav-restore-modal'
import debounce from '@renderer/utils/debounce'
import { useAppConfig } from '@renderer/hooks/use-app-config'
const WebdavConfig: React.FC = () => {
const { appConfig, patchAppConfig } = useAppConfig()
const { webdavUrl, webdavUsername, webdavPassword } = appConfig || {}
const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false)
const [filenames, setFilenames] = useState<string[]>([])
const [restoreOpen, setRestoreOpen] = useState(false)
const [webdav, setWebdav] = useState({ webdavUrl, webdavUsername, webdavPassword })
const setWebdavDebounce = debounce(({ webdavUrl, webdavUsername, webdavPassword }) => {
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword })
}, 500)
const handleBackup = async (): Promise<void> => {
setBackuping(true)
try {
await webdavBackup()
new window.Notification('备份成功', { body: '备份文件已上传至WebDav' })
} catch (e) {
alert(e)
} finally {
setBackuping(false)
}
}
const handleRestore = async (): Promise<void> => {
try {
setRestoring(true)
const filenames = await listWebdavBackups()
setFilenames(filenames)
setRestoreOpen(true)
} catch (e) {
alert(`获取备份列表失败: ${e}`)
} finally {
setRestoring(false)
}
}
return (
<>
{restoreOpen && (
<WebdavRestoreModal filenames={filenames} onClose={() => setRestoreOpen(false)} />
)}
<SettingCard>
<SettingItem title="WebDav地址" divider>
<Input
size="sm"
className="w-[60%]"
value={webdav.webdavUrl}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavUrl: v })
setWebdavDebounce({ ...webdav, webdavUrl: v })
}}
/>
</SettingItem>
<SettingItem title="WebDav用户名" divider>
<Input
size="sm"
className="w-[60%]"
value={webdav.webdavUsername}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavUsername: v })
setWebdavDebounce({ ...webdav, webdavUsername: v })
}}
/>
</SettingItem>
<SettingItem title="WebDav密码" divider>
<Input
size="sm"
className="w-[60%]"
type="password"
value={webdav.webdavPassword}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavPassword: v })
setWebdavDebounce({ ...webdav, webdavPassword: v })
}}
/>
</SettingItem>
<div className="flex justify0between">
<Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}>
</Button>
<Button
isLoading={restoring}
fullWidth
size="sm"
className="ml-1"
onPress={handleRestore}
>
</Button>
</div>
</SettingCard>
</>
)
}
export default WebdavConfig

View File

@ -1,145 +1,15 @@
import { Button, Input, Select, SelectItem, Switch, Tab, Tabs } from '@nextui-org/react'
import { Button } from '@nextui-org/react'
import BasePage from '@renderer/components/base/base-page'
import SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import UpdaterModal from '@renderer/components/updater/updater-modal'
import {
checkAutoRun,
enableAutoRun,
disableAutoRun,
quitApp,
checkUpdate,
patchControledMihomoConfig,
isPortable,
setPortable,
restartCore,
webdavBackup,
listWebdavBackups,
copyEnv,
setNativeTheme
} from '@renderer/utils/ipc'
import { BiCopy } from 'react-icons/bi'
import { CgWebsite } from 'react-icons/cg'
import { IoLogoGithub } from 'react-icons/io5'
import { platform, version } from '@renderer/utils/init'
import useSWR from 'swr'
import { Key, useState } from 'react'
import debounce from '@renderer/utils/debounce'
import { useTheme } from 'next-themes'
import WebdavRestoreModal from '@renderer/components/settings/webdav-restore-modal'
import WebdavConfig from '@renderer/components/settings/webdav-config'
import GeneralConfig from '@renderer/components/settings/general-config'
import MihomoConfig from '@renderer/components/settings/mihomo-config'
import Actions from '@renderer/components/settings/actions'
import ShortcutConfig from '@renderer/components/settings/shortcut-config'
const Settings: React.FC = () => {
const { setTheme } = useTheme()
const { data: enable, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
const { data: portable, mutate: mutatePortable } = useSWR('isPortable', isPortable)
const { appConfig, patchAppConfig } = useAppConfig()
const {
silentStart = false,
controlDns = true,
controlSniff = true,
useDockIcon = true,
showTraffic = true,
proxyInTray = true,
envType = platform === 'win32' ? 'powershell' : 'bash',
delayTestUrl,
delayTestTimeout,
autoCheckUpdate,
userAgent,
autoCloseConnection = true,
appTheme = 'system',
webdavUrl,
webdavUsername,
webdavPassword
} = appConfig || {}
const [newVersion, setNewVersion] = useState('')
const [changelog, setChangelog] = useState('')
const [openUpdate, setOpenUpdate] = useState(false)
const [checkingUpdate, setCheckingUpdate] = useState(false)
const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false)
const [filenames, setFilenames] = useState<string[]>([])
const [restoreOpen, setRestoreOpen] = useState(false)
const [url, setUrl] = useState(delayTestUrl)
const setUrlDebounce = debounce((v: string) => {
patchAppConfig({ delayTestUrl: v })
}, 500)
const [ua, setUa] = useState(userAgent)
const setUaDebounce = debounce((v: string) => {
patchAppConfig({ userAgent: v })
}, 500)
const [webdav, setWebdav] = useState({ webdavUrl, webdavUsername, webdavPassword })
const setWebdavDebounce = debounce(({ webdavUrl, webdavUsername, webdavPassword }) => {
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword })
}, 500)
const onThemeChange = (key: Key, type: 'theme' | 'color'): void => {
const [theme, color] = appTheme.split('-')
if (type === 'theme') {
let themeStr = key.toString()
if (key !== 'system') {
if (color) {
themeStr += `-${color}`
}
}
if (themeStr.includes('light')) {
setNativeTheme('light')
} else if (themeStr === 'system') {
setNativeTheme('system')
} else {
setNativeTheme('dark')
}
setTheme(themeStr)
patchAppConfig({ appTheme: themeStr as AppTheme })
} else {
let themeStr = theme
if (theme !== 'system') {
if (key !== 'blue') {
themeStr += `-${key}`
}
setTheme(themeStr)
patchAppConfig({ appTheme: themeStr as AppTheme })
}
}
}
const handleBackup = async (): Promise<void> => {
setBackuping(true)
try {
await webdavBackup()
new window.Notification('备份成功', { body: '备份文件已上传至WebDav' })
} catch (e) {
alert(e)
} finally {
setBackuping(false)
}
}
const handleRestore = async (): Promise<void> => {
try {
setRestoring(true)
const filenames = await listWebdavBackups()
setRestoring(false)
setFilenames(filenames)
setRestoreOpen(true)
} catch (e) {
alert(`获取备份列表失败: ${e}`)
}
}
return (
<>
{restoreOpen && (
<WebdavRestoreModal filenames={filenames} onClose={() => setRestoreOpen(false)} />
)}
{openUpdate && (
<UpdaterModal
onClose={() => setOpenUpdate(false)}
version={newVersion}
changelog={changelog}
/>
)}
<BasePage
title="应用设置"
header={
@ -168,317 +38,12 @@ const Settings: React.FC = () => {
</>
}
>
<SettingCard>
<SettingItem title="开机自启" divider>
<Switch
size="sm"
isSelected={enable}
onValueChange={async (v) => {
try {
if (v) {
await enableAutoRun()
} else {
await disableAutoRun()
}
} catch (e) {
alert(e)
} finally {
mutateEnable()
}
}}
/>
</SettingItem>
<SettingItem title="自动检查更新" divider>
<Switch
size="sm"
isSelected={autoCheckUpdate}
onValueChange={(v) => {
patchAppConfig({ autoCheckUpdate: v })
}}
/>
</SettingItem>
<SettingItem title="静默启动" divider>
<Switch
size="sm"
isSelected={silentStart}
onValueChange={(v) => {
patchAppConfig({ silentStart: v })
}}
/>
</SettingItem>
<SettingItem
title="复制环境变量类型"
actions={
<Button isIconOnly size="sm" className="ml-2" variant="light" onPress={copyEnv}>
<BiCopy className="text-lg" />
</Button>
}
divider
>
<Select
className="w-[150px]"
size="sm"
selectedKeys={new Set([envType])}
onSelectionChange={async (v) => {
try {
await patchAppConfig({ envType: v.currentKey as 'bash' | 'cmd' | 'powershell' })
} catch (e) {
alert(e)
}
}}
>
<SelectItem key="bash">Bash</SelectItem>
<SelectItem key="cmd">CMD</SelectItem>
<SelectItem key="powershell">PowerShell</SelectItem>
</Select>
</SettingItem>
{platform !== 'linux' && (
<SettingItem title="托盘菜单显示节点信息" divider>
<Switch
size="sm"
isSelected={proxyInTray}
onValueChange={async (v) => {
await patchAppConfig({ proxyInTray: v })
}}
/>
</SettingItem>
)}
{platform === 'darwin' && (
<>
<SettingItem title="显示Dock图标" divider>
<Switch
size="sm"
isSelected={useDockIcon}
onValueChange={async (v) => {
await patchAppConfig({ useDockIcon: v })
}}
/>
</SettingItem>
<SettingItem title="显示网速信息" divider>
<Switch
size="sm"
isSelected={showTraffic}
onValueChange={async (v) => {
await patchAppConfig({ showTraffic: v })
await restartCore()
}}
/>
</SettingItem>
</>
)}
{platform === 'win32' && (
<SettingItem title="数据存储路径" divider>
<Select
className="w-[150px]"
size="sm"
selectedKeys={new Set([portable ? 'portable' : 'data'])}
onSelectionChange={async (v) => {
try {
await setPortable(v.currentKey === 'portable')
} catch (e) {
alert(e)
} finally {
mutatePortable()
}
}}
>
<SelectItem key="data">AppData</SelectItem>
<SelectItem key="portable"></SelectItem>
</Select>
</SettingItem>
)}
<SettingItem title="背景色" divider={appTheme !== 'system'}>
<Tabs
size="sm"
color="primary"
selectedKey={appTheme.split('-')[0]}
onSelectionChange={(key) => {
onThemeChange(key, 'theme')
}}
>
<Tab key="system" title="自动" />
<Tab key="dark" title="深色" />
<Tab key="gray" title="灰色" />
<Tab key="light" title="浅色" />
</Tabs>
</SettingItem>
{appTheme !== 'system' && (
<SettingItem title="主题色">
<Tabs
size="sm"
color="primary"
selectedKey={appTheme.split('-')[1] || 'blue'}
onSelectionChange={(key) => {
onThemeChange(key, 'color')
}}
>
<Tab key="blue" title="蓝色" />
<Tab key="pink" title="粉色" />
<Tab key="green" title="绿色" />
</Tabs>
</SettingItem>
)}
</SettingCard>
<SettingCard>
<SettingItem title="WebDav地址" divider>
<Input
size="sm"
className="w-[60%]"
value={webdav.webdavUrl}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavUrl: v })
setWebdavDebounce({ ...webdav, webdavUrl: v })
}}
/>
</SettingItem>
<SettingItem title="WebDav用户名" divider>
<Input
size="sm"
className="w-[60%]"
value={webdav.webdavUsername}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavUsername: v })
setWebdavDebounce({ ...webdav, webdavUsername: v })
}}
/>
</SettingItem>
<SettingItem title="WebDav密码" divider>
<Input
size="sm"
className="w-[60%]"
type="password"
value={webdav.webdavPassword}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavPassword: v })
setWebdavDebounce({ ...webdav, webdavPassword: v })
}}
/>
</SettingItem>
<div className="flex justify0between">
<Button
isLoading={backuping}
fullWidth
size="sm"
className="mr-1"
onPress={handleBackup}
>
</Button>
<Button
isLoading={restoring}
fullWidth
size="sm"
className="ml-1"
onPress={handleRestore}
>
</Button>
</div>
</SettingCard>
<SettingCard>
<SettingItem title="订阅拉取 UA" divider>
<Input
size="sm"
className="w-[60%]"
value={ua}
placeholder="默认 clash-meta"
onValueChange={(v) => {
setUa(v)
setUaDebounce(v)
}}
></Input>
</SettingItem>
<SettingItem title="延迟测试地址" divider>
<Input
size="sm"
className="w-[60%]"
value={url}
placeholder="默认https://www.gstatic.com/generate_204"
onValueChange={(v) => {
setUrl(v)
setUrlDebounce(v)
}}
></Input>
</SettingItem>
<SettingItem title="延迟测试超时时间" divider>
<Input
type="number"
size="sm"
className="w-[60%]"
value={delayTestTimeout?.toString()}
placeholder="默认5000"
onValueChange={(v) => {
patchAppConfig({ delayTestTimeout: parseInt(v) })
}}
/>
</SettingItem>
<SettingItem title="接管DNS设置" divider>
<Switch
size="sm"
isSelected={controlDns}
onValueChange={async (v) => {
await patchAppConfig({ controlDns: v })
await patchControledMihomoConfig({})
}}
/>
</SettingItem>
<SettingItem title="接管域名嗅探设置" divider>
<Switch
size="sm"
isSelected={controlSniff}
onValueChange={async (v) => {
await patchAppConfig({ controlSniff: v })
await patchControledMihomoConfig({})
}}
/>
</SettingItem>
<SettingItem title="自动断开连接">
<Switch
size="sm"
isSelected={autoCloseConnection}
onValueChange={(v) => {
patchAppConfig({ autoCloseConnection: v })
}}
/>
</SettingItem>
</SettingCard>
<SettingCard>
<SettingItem title="检查更新" divider>
<Button
size="sm"
isLoading={checkingUpdate}
onPress={async () => {
try {
setCheckingUpdate(true)
const version = await checkUpdate()
if (version) {
setNewVersion(version.version)
setChangelog(version.changelog)
setOpenUpdate(true)
} else {
new window.Notification('当前已是最新版本', { body: '无需更新' })
}
} catch (e) {
alert(e)
} finally {
setCheckingUpdate(false)
}
}}
>
</Button>
</SettingItem>
<SettingItem title="退出应用" divider>
<Button size="sm" onPress={quitApp}>
退
</Button>
</SettingItem>
<SettingItem title="应用版本">
<div>v{version}</div>
</SettingItem>
</SettingCard>
<GeneralConfig />
<WebdavConfig />
<MihomoConfig />
<ShortcutConfig />
<Actions />
</BasePage>
</>
)
}

View File

@ -287,6 +287,16 @@ export async function setNativeTheme(theme: 'system' | 'light' | 'dark'): Promis
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('setNativeTheme', theme))
}
export async function registerShortcut(
oldShortcut: string,
newShortcut: string,
action: string
): Promise<boolean> {
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('registerShortcut', oldShortcut, newShortcut, action)
)
}
export async function copyEnv(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('copyEnv'))
}

View File

@ -235,6 +235,14 @@ interface IAppConfig {
webdavPassword?: string
useNameserverPolicy: boolean
nameserverPolicy: { [key: string]: string | string[] }
showWindowShortcut?: string
triggerSysProxyShortcut?: string
triggerTunShortcut?: string
ruleModeShortcut?: string
globalModeShortcut?: string
directModeShortcut?: string
restartAppShortcut?: string
quitAppShortcut?: string
}
interface IMihomoTunConfig {