Files
clash-verge-rev/src/components/setting/mods/update-viewer.tsx
T
Tunglies 6e08a77a84 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
2026-10-12 06:16:05 +00:00

576 lines
15 KiB
TypeScript

import { alpha, Box, Button, LinearProgress } from '@mui/material'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
import {
lazy,
Suspense,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
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,
restartApp,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useSetUpdateState, useUpdateState } from '@/services/states'
import { openExternalUrl } from '@/utils/open-external-url'
type MarkdownNode = {
type: string
value?: string
children?: MarkdownNode[]
data?: {
hProperties?: Record<string, unknown>
}
}
const GITHUB_ALERTS = {
note: {
labelKey: 'settings.modals.update.alerts.note',
color: '#0969da',
},
tip: {
labelKey: 'settings.modals.update.alerts.tip',
color: '#1a7f37',
},
important: {
labelKey: 'settings.modals.update.alerts.important',
color: '#8250df',
},
warning: {
labelKey: 'settings.modals.update.alerts.warning',
color: '#9a6700',
},
caution: {
labelKey: 'settings.modals.update.alerts.caution',
color: '#cf222e',
},
} as const
type GitHubAlertType = keyof typeof GITHUB_ALERTS
const GITHUB_ALERT_PATTERN =
/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*\n?/i
const GITHUB_ALERT_CLASS_PATTERN =
/markdown-alert-(note|tip|important|warning|caution)/
const UPDATE_MARKDOWN_ID_PREFIX = 'update-markdown-'
const shouldShowReleaseNotes = (language: string) => language === 'zh'
const LazyReactMarkdown = lazy(async () => {
const [
{ default: ReactMarkdown },
{ default: rehypeRaw },
{ default: rehypeSanitize, defaultSchema },
{ default: remarkGfm },
] = await Promise.all([
import('react-markdown'),
import('rehype-raw'),
import('rehype-sanitize'),
import('remark-gfm'),
])
const sanitizeSchema = {
...defaultSchema,
clobberPrefix: UPDATE_MARKDOWN_ID_PREFIX,
attributes: {
...defaultSchema.attributes,
blockquote: [
...(defaultSchema.attributes?.blockquote ?? []),
[
'className',
'markdown-alert',
...Object.keys(GITHUB_ALERTS).map((type) => `markdown-alert-${type}`),
],
],
p: [
...(defaultSchema.attributes?.p ?? []),
['className', 'markdown-alert-title'],
],
},
}
return {
default: (props: ReactMarkdownOptions) => (
<ReactMarkdown
{...props}
remarkPlugins={[remarkGfm, ...(props.remarkPlugins ?? [])]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
/>
),
}
})
const getAlertTypeFromClassName = (
className: unknown,
): GitHubAlertType | null => {
const value = Array.isArray(className)
? className.join(' ')
: typeof className === 'string'
? className
: ''
const match = value.match(GITHUB_ALERT_CLASS_PATTERN)
return match?.[1] as GitHubAlertType | null
}
const findFirstTextNode = (node: MarkdownNode): MarkdownNode | null => {
if (node.type === 'text') return node
for (const child of node.children ?? []) {
const result = findFirstTextNode(child)
if (result) return result
}
return null
}
const remarkGitHubAlerts = (labels: Record<GitHubAlertType, string>) => {
const visit = (node: MarkdownNode) => {
for (const child of node.children ?? []) {
visit(child)
}
if (node.type !== 'blockquote') return
const firstTextNode = findFirstTextNode(node)
const match = firstTextNode?.value?.match(GITHUB_ALERT_PATTERN)
if (!firstTextNode?.value || !match) return
const alertType = match[1].toLowerCase() as GitHubAlertType
firstTextNode.value = firstTextNode.value
.replace(GITHUB_ALERT_PATTERN, '')
.replace(/^\n+/, '')
node.data = {
...(node.data ?? {}),
hProperties: {
...(node.data?.hProperties ?? {}),
className: ['markdown-alert', `markdown-alert-${alertType}`],
},
}
node.children?.unshift({
type: 'paragraph',
data: {
hProperties: {
className: 'markdown-alert-title',
},
},
children: [
{
type: 'text',
value: labels[alertType],
},
],
})
}
return visit
}
export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t, i18n } = useTranslation()
const [open, setOpen] = useState(false)
const updateState = useUpdateState()
const setUpdateState = useSetUpdateState()
const { updateInfo } = useUpdate()
const [downloaded, setDownloaded] = useState(0)
const [total, setTotal] = useState(0)
const downloadedRef = useRef(0)
const totalRef = useRef(0)
const openUrlWithNotice = (url: string) => {
void openExternalUrl(url).catch(showNotice.error)
}
const progress = useMemo(() => {
if (total <= 0) return 0
return Math.min((downloaded / total) * 100, 100)
}, [downloaded, total])
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}))
const githubAlertLabels = useMemo(
() =>
Object.fromEntries(
Object.entries(GITHUB_ALERTS).map(([type, alert]) => [
type,
t(alert.labelKey),
]),
) as Record<GitHubAlertType, string>,
[t],
)
const remarkGitHubAlertsPlugin = useMemo(
() => () => remarkGitHubAlerts(githubAlertLabels),
[githubAlertLabels],
)
const activeLanguage = i18n.resolvedLanguage ?? i18n.language
const markdownContent = useMemo(() => {
if (!updateInfo?.body) {
return t('settings.modals.update.messages.available')
}
if (!shouldShowReleaseNotes(activeLanguage)) {
return t('settings.modals.update.messages.available')
}
return updateInfo?.body
}, [activeLanguage, t, updateInfo])
const breakChangeFlag = useMemo(() => {
if (!updateInfo?.body) {
return false
}
return updateInfo?.body.toLowerCase().includes('break change')
}, [updateInfo])
const onUpdate = useLockFn(async () => {
if (!updateInfo?.body) return
if (breakChangeFlag) {
showNotice.error('settings.modals.update.messages.breakChangeError')
return
}
if (updateState) return
setUpdateState(true)
setDownloaded(0)
setTotal(0)
downloadedRef.current = 0
totalRef.current = 0
const onDownloadEvent = (event: DownloadEvent) => {
if (event.event === 'Started') {
const contentLength = event.data.contentLength ?? 0
totalRef.current = contentLength
setTotal(contentLength)
setDownloaded(0)
downloadedRef.current = 0
return
}
if (event.event === 'Progress') {
setDownloaded((prev) => {
const next = prev + event.data.chunkLength
downloadedRef.current = next
return next
})
}
if (event.event === 'Finished' && totalRef.current === 0) {
totalRef.current = downloadedRef.current
setTotal(downloadedRef.current)
}
}
try {
const result = await mutate(
() => installUpdate(updateInfo.version, onDownloadEvent),
{
id: 'install-app-update',
errorNotice: false,
},
)
if (result.ok && result.value) {
await mutate(() => restartApp(), {
id: 'restart-app',
errorNotice: false,
})
}
} catch (err: any) {
showNotice.error(err)
} finally {
setUpdateState(false)
setDownloaded(0)
setTotal(0)
downloadedRef.current = 0
totalRef.current = 0
}
})
return (
<BaseDialog
open={open}
title={
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
minWidth: 0,
}}
>
<Box
component="span"
sx={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{t('settings.modals.update.title', {
version: updateInfo?.version ?? '',
})}
</Box>
<Button
variant="contained"
size="small"
sx={{ whiteSpace: 'nowrap' }}
onClick={() => {
openUrlWithNotice(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
)
}}
>
{t('settings.modals.update.actions.goToRelease')}
</Button>
</Box>
}
contentSx={{
width: { xs: 'calc(100vw - 56px)', sm: 560 },
maxWidth: 'calc(100vw - 56px)',
height: 'min(64vh, 680px)',
display: 'flex',
flexDirection: 'column',
pb: 1,
}}
okBtn={t('settings.modals.update.actions.update')}
cancelBtn={t('shared.actions.cancel')}
onClose={() => setOpen(false)}
onCancel={() => {
if (updateState) {
void mutate(() => cancelUpdateDownload(), {
id: 'cancel-update-download',
errorNotice: false,
}).catch(showNotice.error)
}
setOpen(false)
}}
onOk={onUpdate}
>
<Box
sx={{
flex: 1,
minHeight: 0,
overflow: 'auto',
pr: 1.5,
mr: -1,
fontSize: 14,
lineHeight: 1.65,
color: 'text.primary',
overflowWrap: 'break-word',
'& > :first-child': {
mt: 0,
},
'& > :last-child': {
mb: 0,
},
'& h1': {
mt: 0,
mb: 1.5,
fontSize: 24,
lineHeight: 1.25,
},
'& h2': {
mt: 2.25,
mb: 1,
fontSize: 19,
lineHeight: 1.3,
},
'& h3': {
mt: 2,
mb: 0.75,
fontSize: 16,
lineHeight: 1.35,
},
'& h4, & h5, & h6': {
mt: 1.5,
mb: 0.75,
fontSize: 14,
lineHeight: 1.4,
},
'& p': {
my: 1,
},
'& ul, & ol': {
my: 1,
pl: 2.75,
},
'& li': {
my: 0.35,
pl: 0.25,
},
'& a': {
color: 'primary.main',
overflowWrap: 'anywhere',
},
'& strong': {
fontWeight: 700,
},
'& code': {
px: 0.5,
py: 0.125,
borderRadius: 0.5,
bgcolor: 'action.hover',
fontSize: '0.92em',
},
'& pre': {
my: 1.5,
p: 1.5,
overflow: 'auto',
borderRadius: 1,
bgcolor: 'action.hover',
},
'& pre code': {
p: 0,
bgcolor: 'transparent',
fontSize: '0.9em',
},
'& table': {
display: 'block',
width: '100%',
my: 1.5,
overflowX: 'auto',
borderCollapse: 'collapse',
},
'& th, & td': {
px: 1,
py: 0.75,
border: '1px solid',
borderColor: 'divider',
verticalAlign: 'top',
},
'& th': {
bgcolor: 'action.hover',
fontWeight: 700,
},
'& hr': {
my: 2,
border: 0,
borderTop: '1px solid',
borderColor: 'divider',
},
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: 1,
},
'& blockquote:not(.markdown-alert)': {
m: '12px 0 18px',
pl: 2,
color: 'text.secondary',
borderLeft: '4px solid',
borderColor: 'divider',
},
}}
>
{open && (
<Suspense fallback={<LinearProgress />}>
<LazyReactMarkdown
remarkPlugins={[remarkGitHubAlertsPlugin]}
components={{
a: ({ href, children, ...props }) => {
const isFragment = href?.startsWith('#') ?? false
const renderedHref = isFragment
? `#${UPDATE_MARKDOWN_ID_PREFIX}${href?.slice(1)}`
: href
? '#'
: undefined
return (
<a
{...props}
href={renderedHref}
target={undefined}
rel={undefined}
onClick={(event) => {
if (isFragment) return
event.preventDefault()
if (!href) return
openUrlWithNotice(href)
}}
onAuxClick={(event) => {
if (isFragment) return
event.preventDefault()
if (event.button === 1 && href) {
openUrlWithNotice(href)
}
}}
>
{children}
</a>
)
},
blockquote: ({ className, children }) => {
const alertType = getAlertTypeFromClassName(className)
if (!alertType) {
return (
<blockquote className={className}>{children}</blockquote>
)
}
return (
<Box
component="blockquote"
className={className}
sx={(theme) => {
const color = GITHUB_ALERTS[alertType].color
return {
m: '12px 0 18px',
px: 2,
py: 1,
borderLeft: `4px solid ${color}`,
borderRadius: 1,
bgcolor: alpha(
color,
theme.palette.mode === 'dark' ? 0.16 : 0.08,
),
'& p': {
my: 0.75,
},
'& .markdown-alert-title': {
display: 'flex',
alignItems: 'center',
gap: 0.75,
fontWeight: 700,
lineHeight: 1.4,
},
}
}}
>
{children}
</Box>
)
},
}}
>
{markdownContent}
</LazyReactMarkdown>
</Suspense>
)}
</Box>
{updateState && (
<LinearProgress
variant={total > 0 ? 'determinate' : 'indeterminate'}
value={progress}
sx={{ mt: 1 }}
/>
)}
</BaseDialog>
)
}