mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-26 05:42:07 +08:00
* 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
576 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|