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 } } 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) => ( ), } }) 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) => { 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 }) { 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, [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 ( {t('settings.modals.update.title', { version: updateInfo?.version ?? '', })} } 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} > :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 && ( }> { const isFragment = href?.startsWith('#') ?? false const renderedHref = isFragment ? `#${UPDATE_MARKDOWN_ID_PREFIX}${href?.slice(1)}` : href ? '#' : undefined return ( { 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} ) }, blockquote: ({ className, children }) => { const alertType = getAlertTypeFromClassName(className) if (!alertType) { return (
{children}
) } return ( { 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} ) }, }} > {markdownContent}
)}
{updateState && ( 0 ? 'determinate' : 'indeterminate'} value={progress} sx={{ mt: 1 }} /> )}
) }