use custom theme

This commit is contained in:
pompurin404 2024-09-20 13:30:25 +08:00
parent aae16c3c71
commit ddfa4e75f3
No known key found for this signature in database
11 changed files with 148 additions and 240 deletions

View File

@ -8,7 +8,8 @@ import {
overrideConfigPath,
overrideDir,
profileConfigPath,
profilesDir
profilesDir,
themesDir
} from '../utils/dirs'
export async function webdavBackup(): Promise<boolean> {
@ -21,6 +22,7 @@ export async function webdavBackup(): Promise<boolean> {
zip.addLocalFile(controledMihomoConfigPath())
zip.addLocalFile(profileConfigPath())
zip.addLocalFile(overrideConfigPath())
zip.addLocalFolder(themesDir(), 'themes')
zip.addLocalFolder(profilesDir(), 'profiles')
zip.addLocalFolder(overrideDir(), 'override')
zip.addLocalFolder(overrideDir(), 'substore')

56
src/main/resolve/theme.ts Normal file
View File

@ -0,0 +1,56 @@
import { readdir, readFile } from 'fs/promises'
import { themesDir } from '../utils/dirs'
import path from 'path'
import axios from 'axios'
import AdmZip from 'adm-zip'
import { getControledMihomoConfig } from '../config'
import { existsSync } from 'fs'
import { mainWindow } from '..'
let insertedCSSKey: string | undefined = undefined
export async function resolveThemes(): Promise<{ key: string; label: string }[]> {
const files = await readdir(themesDir())
const themes = await Promise.all(
files.map(async (file) => {
const css = (await readFile(path.join(themesDir(), file), 'utf-8')) || ''
let name = file
if (css.startsWith('/*')) {
name = css.split('\n')[0].replace('/*', '').replace('*/', '').trim() || file
}
return { key: file, label: name }
})
)
return [{ key: 'default.css', label: '默认' }, ...themes]
}
export async function fetchThemes(): Promise<void> {
const zipUrl = 'https://github.com/mihomo-party-org/theme-hub/releases/download/latest/themes.zip'
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const zipData = await axios.get(zipUrl, {
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
})
const zip = new AdmZip(zipData.data as Buffer)
zip.extractAllTo(themesDir(), true)
}
export async function applyTheme(theme: string): Promise<void> {
if (theme === 'default.css') {
if (insertedCSSKey) {
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKey)
}
return
}
if (!existsSync(path.join(themesDir(), theme))) return
const css = await readFile(path.join(themesDir(), theme), 'utf-8')
if (insertedCSSKey) {
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKey)
}
insertedCSSKey = await mainWindow?.webContents.insertCSS(css)
}

View File

@ -53,6 +53,10 @@ export function resourcesFilesDir(): string {
return path.join(resourcesDir(), 'files')
}
export function themesDir(): string {
return path.join(dataDir(), 'themes')
}
export function mihomoCoreDir(): string {
return path.join(resourcesDir(), 'sidecar')
}

View File

@ -11,7 +11,8 @@ import {
profilePath,
profilesDir,
resourcesFilesDir,
subStoreDir
subStoreDir,
themesDir
} from './dirs'
import {
defaultConfig,
@ -38,6 +39,9 @@ async function initDirs(): Promise<void> {
if (!existsSync(dataDir())) {
await mkdir(dataDir())
}
if (!existsSync(themesDir())) {
await mkdir(themesDir())
}
if (!existsSync(profilesDir())) {
await mkdir(profilesDir())
}

View File

@ -65,13 +65,12 @@ import { getInterfaces } from '../sys/interface'
import { copyEnv } from '../resolve/tray'
import { registerShortcut } from '../resolve/shortcut'
import { mainWindow } from '..'
import { applyTheme, fetchThemes, resolveThemes } from '../resolve/theme'
import { subStoreCollections, subStoreSubs } from '../core/subStoreApi'
import { logDir } from './dirs'
import path from 'path'
import v8 from 'v8'
let insertedCSSKey: string | undefined
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
): (...args: any[]) => Promise<T | { invokeError: unknown }> {
@ -203,14 +202,9 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('createHeapSnapshot', () => {
v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`))
})
ipcMain.handle('insertCSS', (_e, css) =>
ipcErrorWrapper(async (css) => {
if (insertedCSSKey) {
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKey)
}
insertedCSSKey = await mainWindow?.webContents.insertCSS(css)
})(css)
)
ipcMain.handle('resolveThemes', () => ipcErrorWrapper(resolveThemes)())
ipcMain.handle('fetchThemes', () => ipcErrorWrapper(fetchThemes)())
ipcMain.handle('applyTheme', (_e, theme) => ipcErrorWrapper(applyTheme)(theme))
ipcMain.handle('copyEnv', ipcErrorWrapper(copyEnv))
ipcMain.handle('alert', (_e, msg) => {
dialog.showErrorBox('Mihomo Party', msg)

View File

@ -28,7 +28,7 @@ import MihomoCoreCard from '@renderer/components/sider/mihomo-core-card'
import ResourceCard from '@renderer/components/sider/resource-card'
import UpdaterButton from '@renderer/components/updater/updater-button'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { insertCSS, setNativeTheme, setTitleBarOverlay } from '@renderer/utils/ipc'
import { applyTheme, setNativeTheme, setTitleBarOverlay } from '@renderer/utils/ipc'
import { platform } from '@renderer/utils/init'
import { TitleBarOverlayOptions } from 'electron'
import SubStoreCard from '@renderer/components/sider/substore-card'
@ -42,8 +42,8 @@ const App: React.FC = () => {
const { appConfig, patchAppConfig } = useAppConfig()
const {
appTheme = 'system',
customTheme,
useWindowFrame = false,
injectCSS,
siderOrder = [
'sysproxy',
'tun',
@ -79,15 +79,10 @@ const App: React.FC = () => {
}
}, [])
useEffect(() => {
if (!injectCSS) return
console.log('injectCSS', injectCSS)
insertCSS(injectCSS)
}, [injectCSS])
useEffect(() => {
setNativeTheme(appTheme)
setTheme(appTheme)
if (customTheme) applyTheme(customTheme)
if (!useWindowFrame) {
const options = { height: 48 } as TitleBarOverlayOptions
try {
@ -100,7 +95,7 @@ const App: React.FC = () => {
// ignore
}
}
}, [appTheme, systemTheme])
}, [appTheme, systemTheme, customTheme])
const onDragEnd = async (event: DragEndEvent): Promise<void> => {
const { active, over } = event

View File

@ -1,45 +0,0 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
import { BaseEditor } from '@renderer/components/base/base-editor'
import React, { useState } from 'react'
interface Props {
css: string
onCancel: () => void
onConfirm: (script: string) => void
}
const CSSEditorModal: React.FC<Props> = (props) => {
const { css, onCancel, onConfirm } = props
const [currData, setCurrData] = useState(css)
return (
<Modal
backdrop="blur"
classNames={{ backdrop: 'top-[48px]' }}
size="5xl"
hideCloseButton
isOpen={true}
onOpenChange={onCancel}
scrollBehavior="inside"
>
<ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0"> CSS</ModalHeader>
<ModalBody className="h-full">
<BaseEditor
language="css"
value={currData}
onChange={(value) => setCurrData(value || '')}
/>
</ModalBody>
<ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onCancel}>
</Button>
<Button size="sm" color="primary" onPress={() => onConfirm(currData)}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default CSSEditorModal

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { Button, Input, Select, SelectItem, Switch, Tab, Tabs, Tooltip } from '@nextui-org/react'
@ -9,19 +9,23 @@ import {
copyEnv,
disableAutoRun,
enableAutoRun,
fetchThemes,
relaunchApp,
resolveThemes,
restartCore
} from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { platform } from '@renderer/utils/init'
import { useTheme } from 'next-themes'
import { IoIosHelpCircle } from 'react-icons/io'
import CSSEditorModal from './css-editor-modal'
import { IoIosHelpCircle, IoMdCloudDownload } from 'react-icons/io'
const GeneralConfig: React.FC = () => {
const { data: enable, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
const { appConfig, patchAppConfig } = useAppConfig()
const [openCSSEditor, setOpenCSSEditor] = useState(false)
const [customThemes, setCustomThemes] = React.useState([
{ key: 'default.css', label: '默认', content: '' }
])
const [fetching, setFetching] = useState(false)
const { setTheme } = useTheme()
const {
silentStart = false,
@ -31,24 +35,20 @@ const GeneralConfig: React.FC = () => {
useWindowFrame = false,
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
injectCSS = DEFAULT_CSS,
customTheme = 'default.css',
envType = platform === 'win32' ? 'powershell' : 'bash',
autoCheckUpdate,
appTheme = 'system'
} = appConfig || {}
useEffect(() => {
resolveThemes().then((themes) => {
setCustomThemes(themes)
})
})
return (
<>
{openCSSEditor && (
<CSSEditorModal
css={injectCSS}
onCancel={() => setOpenCSSEditor(false)}
onConfirm={async (css: string) => {
await patchAppConfig({ injectCSS: css })
setOpenCSSEditor(false)
}}
/>
)}
<SettingCard>
<SettingItem title="开机自启" divider>
<Switch
@ -194,12 +194,7 @@ const GeneralConfig: React.FC = () => {
}}
/>
</SettingItem>
<SettingItem title="自定义样式" divider>
<Button size="sm" onPress={() => setOpenCSSEditor(true)} variant="bordered">
CSS
</Button>
</SettingItem>
<SettingItem title="背景色">
<SettingItem title="背景色" divider>
<Tabs
size="sm"
color="primary"
@ -214,157 +209,52 @@ const GeneralConfig: React.FC = () => {
<Tab key="light" title="浅色" />
</Tabs>
</SettingItem>
<SettingItem
title="主题"
actions={
<Button
size="sm"
isLoading={fetching}
isIconOnly
title="拉取主题"
variant="light"
className="ml-2"
onPress={async () => {
setFetching(true)
try {
await fetchThemes()
setCustomThemes(await resolveThemes())
} catch (e) {
alert(e)
} finally {
setFetching(false)
}
}}
>
<IoMdCloudDownload className="text-lg" />
</Button>
}
>
<Select
className="w-[60%]"
size="sm"
selectedKeys={new Set([customTheme])}
onSelectionChange={async (v) => {
try {
await patchAppConfig({ customTheme: v.currentKey as string })
} catch (e) {
alert(e)
}
}}
>
{customThemes.map((theme) => (
<SelectItem key={theme.key}>{theme.label}</SelectItem>
))}
</Select>
</SettingItem>
</SettingCard>
</>
)
}
const DEFAULT_CSS = `/* 使用 !important 以覆盖默认样式 */
/* --nextui-xxx 变量只支持hsl色值 */
/* 若要对所有主题生效可直接给html元素设置样式 */
/* 深色-蓝色 */
.dark, [data-theme="dark"] {
--nextui-background: 0 0% 0%;
--nextui-foreground-50: 240 5.88% 10%;
--nextui-foreground-100: 240 3.7% 15.88%;
--nextui-foreground-200: 240 5.26% 26.08%;
--nextui-foreground-300: 240 5.2% 33.92%;
--nextui-foreground-400: 240 3.83% 46.08%;
--nextui-foreground-500: 240 5.03% 64.9%;
--nextui-foreground-600: 240 4.88% 83.92%;
--nextui-foreground-700: 240 5.88% 90%;
--nextui-foreground-800: 240 4.76% 95.88%;
--nextui-foreground-900: 0 0% 98.04%;
--nextui-foreground: 210 5.56% 92.94%;
--nextui-focus: 212.01999999999998 100% 46.67%;
--nextui-overlay: 0 0% 0%;
--nextui-divider: 0 0% 100%;
--nextui-divider-opacity: 0.15;
--nextui-content1: 240 5.88% 10%;
--nextui-content1-foreground: 0 0% 98.04%;
--nextui-content2: 240 3.7% 15.88%;
--nextui-content2-foreground: 240 4.76% 95.88%;
--nextui-content3: 240 5.26% 26.08%;
--nextui-content3-foreground: 240 5.88% 90%;
--nextui-content4: 240 5.2% 33.92%;
--nextui-content4-foreground: 240 4.88% 83.92%;
--nextui-default-50: 240 5.88% 10%;
--nextui-default-100: 240 3.7% 15.88%;
--nextui-default-200: 240 5.26% 26.08%;
--nextui-default-300: 240 5.2% 33.92%;
--nextui-default-400: 240 3.83% 46.08%;
--nextui-default-500: 240 5.03% 64.9%;
--nextui-default-600: 240 4.88% 83.92%;
--nextui-default-700: 240 5.88% 90%;
--nextui-default-800: 240 4.76% 95.88%;
--nextui-default-900: 0 0% 98.04%;
--nextui-default-foreground: 0 0% 100%;
--nextui-default: 240 5.26% 26.08%;
--nextui-primary-50: 211.84000000000003 100% 9.61%;
--nextui-primary-100: 211.84000000000003 100% 19.22%;
--nextui-primary-200: 212.24 100% 28.82%;
--nextui-primary-300: 212.14 100% 38.43%;
--nextui-primary-400: 212.01999999999998 100% 46.67%;
--nextui-primary-500: 212.14 92.45% 58.43%;
--nextui-primary-600: 212.24 92.45% 68.82%;
--nextui-primary-700: 211.84000000000003 92.45% 79.22%;
--nextui-primary-800: 211.84000000000003 92.45% 89.61%;
--nextui-primary-900: 212.5 92.31% 94.9%;
--nextui-primary: 212.01999999999998 100% 46.67%;
--nextui-primary-foreground: 0 0% 100%;
--nextui-secondary-50: 270 66.67% 9.41%;
--nextui-secondary-100: 270 66.67% 18.82%;
--nextui-secondary-200: 270 66.67% 28.24%;
--nextui-secondary-300: 270 66.67% 37.65%;
--nextui-secondary-400: 270 66.67% 47.06%;
--nextui-secondary-500: 270 59.26% 57.65%;
--nextui-secondary-600: 270 59.26% 68.24%;
--nextui-secondary-700: 270 59.26% 78.82%;
--nextui-secondary-800: 270 59.26% 89.41%;
--nextui-secondary-900: 270 61.54% 94.9%;
--nextui-secondary-foreground: 0 0% 100%;
--nextui-secondary: 270 59.26% 57.65%;
--nextui-success-50: 145.71000000000004 77.78% 8.82%;
--nextui-success-100: 146.2 79.78% 17.45%;
--nextui-success-200: 145.78999999999996 79.26% 26.47%;
--nextui-success-300: 146.01 79.89% 35.1%;
--nextui-success-400: 145.96000000000004 79.46% 43.92%;
--nextui-success-500: 146.01 62.45% 55.1%;
--nextui-success-600: 145.78999999999996 62.57% 66.47%;
--nextui-success-700: 146.2 61.74% 77.45%;
--nextui-success-800: 145.71000000000004 61.4% 88.82%;
--nextui-success-900: 146.66999999999996 64.29% 94.51%;
--nextui-success-foreground: 0 0% 0%;
--nextui-success: 145.96000000000004 79.46% 43.92%;
--nextui-warning-50: 37.139999999999986 75% 10.98%;
--nextui-warning-100: 37.139999999999986 75% 21.96%;
--nextui-warning-200: 36.95999999999998 73.96% 33.14%;
--nextui-warning-300: 37.00999999999999 74.22% 44.12%;
--nextui-warning-400: 37.02999999999997 91.27% 55.1%;
--nextui-warning-500: 37.00999999999999 91.26% 64.12%;
--nextui-warning-600: 36.95999999999998 91.24% 73.14%;
--nextui-warning-700: 37.139999999999986 91.3% 81.96%;
--nextui-warning-800: 37.139999999999986 91.3% 90.98%;
--nextui-warning-900: 54.55000000000001 91.67% 95.29%;
--nextui-warning-foreground: 0 0% 0%;
--nextui-warning: 37.02999999999997 91.27% 55.1%;
--nextui-danger-50: 340 84.91% 10.39%;
--nextui-danger-100: 339.3299999999999 86.54% 20.39%;
--nextui-danger-200: 339.11 85.99% 30.78%;
--nextui-danger-300: 339 86.54% 40.78%;
--nextui-danger-400: 339.20000000000005 90.36% 51.18%;
--nextui-danger-500: 339 90% 60.78%;
--nextui-danger-600: 339.11 90.6% 70.78%;
--nextui-danger-700: 339.3299999999999 90% 80.39%;
--nextui-danger-800: 340 91.84% 90.39%;
--nextui-danger-900: 339.13 92% 95.1%;
--nextui-danger-foreground: 0 0% 100%;
--nextui-danger: 339.20000000000005 90.36% 51.18%;
--nextui-divider-weight: 1px;
--nextui-disabled-opacity: .5;
--nextui-font-size-tiny: 0.75rem;
--nextui-font-size-small: 0.875rem;
--nextui-font-size-medium: 1rem;
--nextui-font-size-large: 1.125rem;
--nextui-line-height-tiny: 1rem;
--nextui-line-height-small: 1.25rem;
--nextui-line-height-medium: 1.5rem;
--nextui-line-height-large: 1.75rem;
--nextui-radius-small: 8px;
--nextui-radius-medium: 12px;
--nextui-radius-large: 14px;
--nextui-border-width-small: 1px;
--nextui-border-width-medium: 2px;
--nextui-border-width-large: 3px;
--nextui-box-shadow-small: 0px 0px 5px 0px rgb(0 0 0 / 0.05), 0px 2px 10px 0px rgb(0 0 0 / 0.2), inset 0px 0px 1px 0px rgb(255 255 255 / 0.15);
--nextui-box-shadow-medium: 0px 0px 15px 0px rgb(0 0 0 / 0.06), 0px 2px 30px 0px rgb(0 0 0 / 0.22), inset 0px 0px 1px 0px rgb(255 255 255 / 0.15);
--nextui-box-shadow-large: 0px 0px 30px 0px rgb(0 0 0 / 0.07), 0px 30px 60px 0px rgb(0 0 0 / 0.26), inset 0px 0px 1px 0px rgb(255 255 255 / 0.15);
--nextui-hover-opacity: .9;
}
/* 灰色-蓝色 */
.gray, [data-theme="gray"] {
}
/* 浅色-蓝色 */
.light, [data-theme="light"] {
}
/* 深色-粉色 */
.dark-pink, [data-theme="dark-pink"] {
}
/* 灰色-粉色 */
.gray-pink, [data-theme="gray-pink"] {
}
/* 浅色-粉色 */
.light-pink, [data-theme="light-pink"] {
}
/* 深色-绿色 */
.dark-green, [data-theme="dark-green"] {
}
/* 灰色-绿色 */
.gray-green, [data-theme="gray-green"] {
}
/* 浅色-绿色 */
.light-green, [data-theme="light-green"] {
}
`
export default GeneralConfig

View File

@ -19,7 +19,7 @@ let drawing = false
const ConnCard: React.FC = () => {
const { theme = 'system', systemTheme = 'dark' } = useTheme()
const { appConfig } = useAppConfig()
const { showTraffic, connectionCardStatus = 'col-span-2' } = appConfig || {}
const { showTraffic, connectionCardStatus = 'col-span-2', customTheme } = appConfig || {}
const location = useLocation()
const match = location.pathname.includes('/connections')
@ -43,7 +43,7 @@ const ConnCard: React.FC = () => {
: islight
? window.getComputedStyle(document.documentElement).color
: 'rgb(255,255,255)'
}, [theme, systemTheme, match])
}, [theme, systemTheme, match, customTheme])
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
useEffect(() => {

View File

@ -331,8 +331,16 @@ export async function createHeapSnapshot(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('createHeapSnapshot'))
}
export async function insertCSS(css: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('insertCSS', css))
export async function resolveThemes(): Promise<{ key: string; label: string; content: string }[]> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('resolveThemes'))
}
export async function fetchThemes(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('fetchThemes'))
}
export async function applyTheme(theme: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('applyTheme', theme))
}
export async function registerShortcut(

View File

@ -223,7 +223,6 @@ interface IAppConfig {
substoreCardStatus?: CardStatus
sysproxyCardStatus?: CardStatus
tunCardStatus?: CardStatus
injectCSS?: string
useSubStore: boolean
subStoreBackendSyncCron?: string
subStoreBackendDownloadCron?: string
@ -238,6 +237,7 @@ interface IAppConfig {
proxyInTray: boolean
siderOrder: string[]
appTheme: AppTheme
customTheme: string
autoCheckUpdate: boolean
silentStart: boolean
autoCloseConnection: boolean