refactor(backend): specta ipc bindings (#8195)

* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
This commit is contained in:
Tunglies
2026-10-12 06:16:05 +00:00
committed by GitHub
parent 27efaf34f4
commit 6e08a77a84
89 changed files with 2714 additions and 1354 deletions
+4 -4
View File
@@ -423,7 +423,7 @@ interface PersistentProxySelectProps
value: string
selectedName: string
groupName: string
fixed?: string
fixed?: string | null
selectedDelay: number
options: ProxyOption[]
onChange: (value: string) => void
@@ -785,8 +785,8 @@ export const CurrentProxyCard = () => {
// The profile selection is the durable source across core restarts and run modes.
handleSelectChange(
selectedGroup.name,
previousProxy,
selectedGroup.fixed,
previousProxy!,
selectedGroup.fixed!,
)({
target: { value: nextName },
})
@@ -940,7 +940,7 @@ export const CurrentProxyCard = () => {
unsortedProxyOptions,
sortType,
delays.of,
defaultLatencyTimeout,
defaultLatencyTimeout!,
),
[
delays,
+9 -25
View File
@@ -56,26 +56,7 @@ const openProfileHome = (url: string) => {
void openExternalUrl(url).catch(showNotice.error)
}
// 使用类型定义,而不是导入
interface ProfileExtra {
upload: number
download: number
total: number
expire: number
}
interface ProfileItem {
uid: string
type?: 'local' | 'remote' | 'merge' | 'script'
name?: string
desc?: string
file?: string
url?: string
updated?: number
extra?: ProfileExtra
home?: string
option?: any
}
type ProfileItem = IProfileItem
interface HomeProfileCardProps {
current: ProfileItem | null | undefined
@@ -298,10 +279,13 @@ export const HomeProfileCard = ({
setUpdating(true)
try {
await mutate(() => updateProfile(current.uid, current.option), {
id: `update-profile:${current.uid}`,
errorNotice: false,
})
await mutate(
() => updateProfile(current.uid!, current.option ?? undefined),
{
id: `update-profile:${current.uid}`,
errorNotice: false,
},
)
onProfileUpdated?.()
// 刷新首页数据
@@ -345,7 +329,7 @@ export const HomeProfileCard = ({
flex: 1,
},
}}
title={current.name}
title={current.name!}
>
<span>{current.name}</span>
<LaunchOutlined
+1 -1
View File
@@ -201,7 +201,7 @@ export const ProxyTunCard: FC = () => {
onClick={() => handleTabChange('tun')}
icon={TroubleshootRounded}
label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode && isTunModeAvailable}
hasIndicator={enable_tun_mode! && isTunModeAvailable}
/>
</Stack>
+1 -1
View File
@@ -164,7 +164,7 @@ export const TestCard = () => {
}}
>
{testList.map((item, index) => (
<SortableItem key={item.uid} id={item.uid} index={index}>
<SortableItem key={item.uid} id={item.uid!} index={index}>
<TestItem
itemData={item}
onEdit={() => viewerRef.current?.edit(item)}
+18 -18
View File
@@ -120,7 +120,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
)
const setLoading = useCallback(
(loading: boolean) => {
setProfileLoading([itemData.uid], loading)
setProfileLoading([itemData.uid!], loading)
},
[itemData.uid, setProfileLoading],
)
@@ -140,7 +140,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
debugLog(`强制刷新定时器任务`)
}
const nextUpdate = await getNextUpdateTime(itemData.uid)
const nextUpdate = await getNextUpdateTime(itemData.uid!)
debugLog(`获取到下次更新时间结果:`, nextUpdate)
if (nextUpdate) {
@@ -245,7 +245,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const hasHome = !!itemData.home // only subscription url has home page
const { upload = 0, download = 0, total = 0 } = extra ?? {}
const from = parseUrl(itemData.url)
const from = parseUrl(itemData.url!)
const description = itemData.desc
const expire = parseExpire(extra?.expire)
const progress = Math.min(
@@ -253,7 +253,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
100,
)
const loading = loadingCache.has(itemData.uid)
const loading = loadingCache.has(itemData.uid!)
const [, forceRefresh] = useReducer((value: number) => value + 1, 0)
useEffect(() => {
@@ -263,7 +263,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handler = () => {
const now = Date.now()
const lastUpdate = updated * 1000
const lastUpdate = updated! * 1000
if (now - lastUpdate >= 24 * 36e5) return
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4
@@ -293,7 +293,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const [confirmOpen, setConfirmOpen] = useState(false)
const [qrOpen, setQrOpen] = useState(false)
const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid])
const loadProfileDocument = useCallback(() => readProfileFile(uid!), [uid])
const loadMergeDocument = useCallback(
() => readProfileFile(option?.merge ?? ''),
[option?.merge],
@@ -370,7 +370,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const onOpenFile = useLockFn(async () => {
setAnchorEl(null)
try {
await mutate(() => viewProfile(itemData.uid), {
await mutate(() => viewProfile(itemData.uid!), {
id: 'view-profile',
errorNotice: false,
})
@@ -402,7 +402,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
try {
const payload = Object.keys(option).length > 0 ? option : undefined
await mutate(() => updateProfile(itemData.uid, payload), {
await mutate(() => updateProfile(itemData.uid!, payload), {
id: `update-profile:${itemData.uid}`,
errorNotice: false,
})
@@ -593,7 +593,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handleSaveProfileDocument = useLockFn(async () => {
const currentValue = profileDocument.value
const result = await mutate(() => saveProfileFile(uid, currentValue), {
const result = await mutate(() => saveProfileFile(uid!, currentValue), {
id: `save-profile-file:${uid}`,
errorNotice: false,
})
@@ -728,7 +728,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
variant="h6"
component="h2"
noWrap
title={name}
title={name!}
>
{name}
</Typography>
@@ -794,7 +794,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
title={
showNextUpdate
? t('profiles.components.profileItem.tooltips.showLast')
: `${t('shared.labels.updateTime')}: ${parseExpire(updated)}\n${t('profiles.components.profileItem.tooltips.showNext')}`
: `${t('shared.labels.updateTime')}: ${parseExpire(updated!)}\n${t('profiles.components.profileItem.tooltips.showNext')}`
}
sx={{
fontSize: 14,
@@ -812,8 +812,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
>
{showNextUpdate
? nextUpdateTime
: updated > 0
? dayjs(updated * 1000).fromNow()
: updated! > 0
? dayjs(updated! * 1000).fromNow()
: ''}
</Typography>
</Box>
@@ -831,7 +831,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
) : (
<Box sx={{ ...boxStyle, fontSize: 12, justifyContent: 'flex-end' }}>
<span title={t('shared.labels.updateTime')}>
{parseExpire(updated)}
{parseExpire(updated!)}
</span>
</Box>
)}
@@ -896,7 +896,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
<RulesEditorViewer
groupsUid={option?.groups ?? ''}
mergeUid={option?.merge ?? ''}
profileUid={uid}
profileUid={uid!}
property={option?.rules ?? ''}
open={true}
onSave={onSave}
@@ -905,7 +905,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
)}
{proxiesOpen && (
<ProxiesEditorViewer
profileUid={uid}
profileUid={uid!}
property={option?.proxies ?? ''}
open={true}
onSave={onSave}
@@ -916,7 +916,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
<GroupsEditorViewer
mergeUid={option?.merge ?? ''}
proxiesUid={option?.proxies ?? ''}
profileUid={uid}
profileUid={uid!}
property={option?.groups ?? ''}
open={true}
onSave={onSave}
@@ -976,7 +976,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
{qrOpen && itemData.url && (
<QrViewer
open={true}
value={`${itemData.url}${itemData.url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`}
value={`${itemData.url}${itemData.url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name!)}`}
onClose={() => setQrOpen(false)}
/>
)}
+14 -8
View File
@@ -115,7 +115,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
}
const name = form.name || `${form.type} file`
const item = { ...form, name, option }
const item = {
type: form.type,
name,
desc: form.desc,
option,
...(form.type === 'remote' ? { url: form.url } : {}),
}
const isRemote = form.type === 'remote'
const isUpdate = openType === 'edit'
@@ -139,7 +145,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
t('profiles.modals.profileForm.errors.uidMissing'),
)
}
await mutate(() => patchProfile(form.uid, item), {
await mutate(() => patchProfile(form.uid!, item), {
id: 'patch-profile',
errorNotice: false,
})
@@ -157,7 +163,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
t('profiles.modals.profileForm.errors.uidMissing'),
)
}
await mutate(() => patchProfile(form.uid, item), {
await mutate(() => patchProfile(form.uid!, item), {
id: 'patch-profile',
errorNotice: false,
})
@@ -190,13 +196,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
t('profiles.modals.profileForm.errors.uidMissing'),
)
}
await mutate(() => patchProfile(form.uid, retryItem), {
await mutate(() => patchProfile(form.uid!, retryItem), {
id: 'patch-profile',
errorNotice: false,
})
await mutate(
() => patchProfile(form.uid, { option: originalOptions }),
() => patchProfile(form.uid!, { option: originalOptions }),
{
id: 'patch-profile',
errorNotice: false,
@@ -413,7 +419,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel>
{t('profiles.modals.profileForm.fields.useSystemProxy')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
<Switch checked={field.value!} {...field} color="primary" />
</StyledBox>
)}
/>
@@ -426,7 +432,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel>
{t('profiles.modals.profileForm.fields.useClashProxy')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
<Switch checked={field.value!} {...field} color="primary" />
</StyledBox>
)}
/>
@@ -439,7 +445,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel>
{t('profiles.modals.profileForm.fields.acceptInvalidCerts')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
<Switch checked={field.value!} {...field} color="primary" />
</StyledBox>
)}
/>
+28 -19
View File
@@ -445,10 +445,14 @@ export const ProxyChain = ({
if (isConnected) {
setIsConnecting(true)
try {
await mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
const result = await mutate(
() => updateProxyChainConfigInRuntime(null),
{
id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
const targetGroup =
mode === 'global'
@@ -461,7 +465,7 @@ export const ProxyChain = ({
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
})
recordSelection(targetGroup, 'DIRECT')
await recordSelection(targetGroup, 'DIRECT')
} catch {
if (currentProxyChain.length >= 1) {
try {
@@ -473,7 +477,7 @@ export const ProxyChain = ({
errorNotice: false,
},
)
recordSelection(targetGroup, currentProxyChain[0].name)
await recordSelection(targetGroup, currentProxyChain[0].name)
} catch {
// ignore
}
@@ -519,10 +523,14 @@ export const ProxyChain = ({
// 第一步:保存链式代理配置
const chainProxies = currentProxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies)
await mutate(() => updateProxyChainConfigInRuntime(chainProxies), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
const result = await mutate(
() => updateProxyChainConfigInRuntime(chainProxies),
{
id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点
@@ -545,12 +553,12 @@ export const ProxyChain = ({
)
// The chain moves the group like any other selection, so the profile has to learn about
// it: what the profile holds is what gets re-applied the next time the core starts.
recordSelection(targetGroup || 'GLOBAL', lastNode.name)
await recordSelection(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem('proxy-chain-exit-node', lastNode.name)
// 刷新代理信息以更新连接状态
refreshProxy()
await refreshProxy()
debugLog('Successfully connected to proxy chain')
} catch (error) {
console.error('Failed to connect to proxy chain:', error)
@@ -619,14 +627,15 @@ export const ProxyChain = ({
<IconButton
size="small"
onClick={() => {
mutate(() => updateProxyChainConfigInRuntime(null), {
void mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
onUpdateChain([])
onFulfilled: () => {
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
onUpdateChain([])
},
}).catch(() => {})
}}
sx={{
color: theme.palette.error.main,
@@ -5,11 +5,11 @@ export type ProxyEmptyStateReason =
| 'no-proxy-info'
type ProfileSummary = {
current?: string
current?: string | null
items?: Array<{
uid?: string
type?: string
}>
uid?: string | null
type?: string | null
}> | null
}
interface ProxyListStateInput {
@@ -20,7 +20,7 @@ interface ProxyListStateInput {
isRunningModePending: boolean
}
const isSubscriptionProfile = (item: { type?: string }): boolean =>
const isSubscriptionProfile = (item: { type?: string | null }): boolean =>
item.type === 'remote' || item.type === 'local'
/** Decides only pre-render states; the built list owns whether it has renderable content. */
+14 -8
View File
@@ -400,20 +400,26 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
setRuleMenuAnchor(null)
}
const handleGroupSelect = (groupName: string) => {
onGroupSelect(groupName)
handleGroupMenuClose()
const handleGroupSelect = async (groupName: string) => {
if (mode === 'rule') {
mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
try {
const result = await mutate(
() => updateProxyChainConfigInRuntime(null),
{
id: 'update-proxy-chain-runtime',
},
)
if (!result.ok) return
} catch {
return
}
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
setProxyChain([])
}
onGroupSelect(groupName)
handleGroupMenuClose()
}
const handleCloseDuplicateWarning = useCallback(() => {
+2 -2
View File
@@ -198,7 +198,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
{toolsOnLeft && proxyCount}
<ProxyGroupTools
side={toolsOnLeft ? 'left' : 'right'}
url={group.testUrl}
url={group.testUrl!}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
@@ -247,7 +247,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
return (
<ProxyHead
sx={{ pl: 2, pr: 3, mt: 0.5, mb: 1 }}
url={group.testUrl}
url={group.testUrl!}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
@@ -17,7 +17,6 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
history: [{ delay }],
extra: {
'http://cp.cloudflare.com/generate_204': {
alive: true,
history: [{ time: new Date(0).toISOString(), delay }],
},
},
+7 -7
View File
@@ -110,13 +110,13 @@ const CHAIN_DELAY_GROUP = 'chain-mode'
const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
name: CHAIN_DELAY_GROUP,
type: 'Selector',
alive: true,
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
history: [],
extra: {},
members,
})
@@ -237,7 +237,7 @@ export const useRenderList = (
selected?.name ?? CHAIN_DELAY_GROUP,
'',
0,
latencyTimeout,
latencyTimeout!,
)
if (col > 1) {
return groupOccurrences(occurrences, col).map((memberCol) => ({
@@ -289,8 +289,8 @@ export const useRenderList = (
key: group.name,
group,
headState,
icon: group.icon,
testUrl: group.testUrl,
icon: group.icon!,
testUrl: group.testUrl!,
},
]
@@ -300,7 +300,7 @@ export const useRenderList = (
group.name,
headState.filterText,
headState.sortType,
latencyTimeout,
latencyTimeout!,
{
matchCase: headState.filterMatchCase,
matchWholeWord: headState.filterMatchWholeWord,
@@ -337,11 +337,11 @@ export const useRenderList = (
}
cache.set(group.name, {
now: group.now,
now: group.now!,
members: group.members,
headState,
col,
latencyTimeout,
latencyTimeout: latencyTimeout!,
delays: groupDelays.get(group.name),
items: ret,
})
@@ -50,9 +50,9 @@ export const BackupConfigViewer = memo(
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: {
url: webdav_url,
username: webdav_username,
password: webdav_password,
url: webdav_url!,
username: webdav_username!,
password: webdav_password!,
},
})
const url = watch('url')
+1 -1
View File
@@ -196,7 +196,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { verge } = useVerge()
const { current: currentProfile } = useProfiles()
const dnsEnabled = currentProfile
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ??
? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ??
verge?.enable_dns_settings ??
false)
: false
@@ -438,7 +438,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<Switch
edge="end"
disabled={!enabled}
checked={value.pac}
checked={value.pac!}
onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))}
/>
</ListItem>
@@ -455,7 +455,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<Switch
edge="end"
disabled={!enabled}
checked={value.guard}
checked={value.guard!}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
sx={{ marginLeft: 'auto' }}
/>
@@ -1,5 +1,4 @@
import { alpha, Box, Button, LinearProgress } from '@mui/material'
import type { DownloadEvent } from '@tauri-apps/plugin-updater'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
import {
@@ -15,6 +14,7 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown'
import { BaseDialog, DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
import type { DownloadEvent } from '@/services/bindings'
import {
cancelUpdateDownload,
installUpdate,
+1 -1
View File
@@ -40,7 +40,7 @@ const SettingClash = ({ onError }: Props) => {
const { verge, mutateVerge } = useVerge()
const { current: currentProfile } = useProfiles()
const dnsEnabled = currentProfile
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ??
? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ??
verge?.enable_dns_settings ??
false)
: false
+13 -2
View File
@@ -9,6 +9,7 @@ import { useVerge } from '@/hooks/use-verge'
import { navigationItems } from '@/pages/_navigation-meta'
import { copyClashEnv } from '@/services/cmds'
import { supportedLanguages } from '@/services/i18n'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
@@ -75,8 +76,18 @@ const SettingVergeBasic = ({ onError }: Props) => {
}
const onCopyClashEnv = useCallback(async () => {
await copyClashEnv()
showNotice.success('shared.feedback.notifications.common.copySuccess', 1000)
try {
await mutate(copyClashEnv, {
id: 'copy-clash-env',
onFulfilled: () =>
showNotice.success(
'shared.feedback.notifications.common.copySuccess',
1000,
),
})
} catch {
// The shared mutation boundary reports the failure.
}
}, [])
return (
+6 -6
View File
@@ -25,11 +25,11 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const [position, setPosition] = useState({ left: 0, top: 0 })
const [delay, setDelay] = useState(-1)
const { uid, name, icon, url } = itemData
const iconCachePath = useIconCache({ icon, cacheKey: uid })
const iconCachePath = useIconCache({ icon: icon!, cacheKey: uid! })
const onDelay = useCallback(async () => {
setDelay(-2)
const result = await cmdTestDelay(url)
const result = await cmdTestDelay(url!)
setDelay(result)
}, [url])
@@ -41,7 +41,7 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const onDelete = useLockFn(async () => {
setAnchorEl(null)
try {
removeTest(uid)
removeTest(uid!)
} catch (err: any) {
showNotice.error(err)
}
@@ -81,17 +81,17 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{icon.trim().startsWith('http') && (
<img
alt={name}
alt={name!}
src={iconCachePath === '' ? icon : iconCachePath}
height="40px"
/>
)}
{icon.trim().startsWith('data') && (
<img alt={name} src={icon} height="40px" />
<img alt={name!} src={icon!} height="40px" />
)}
{icon.trim().startsWith('<svg') && (
<img
alt={name}
alt={name!}
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(icon)}`}
height="40px"
/>