feat: add update info ui

This commit is contained in:
kangfenmao 2024-12-10 17:06:29 +08:00
parent aeff59946c
commit b5a109401c
14 changed files with 197 additions and 73 deletions

View File

@ -1,6 +1,8 @@
# provider: generic
# url: http://127.0.0.1:8080
# updaterCacheDirName: cherry-studio-updater
provider: github
repo: cherry-studio
owner: kangfenmao
# provider: github
# repo: cherry-studio
# owner: kangfenmao
provider: generic
url: https://cherrystudio.ocool.online

View File

@ -55,9 +55,8 @@ appImage:
artifactName: ${productName}-${version}-${arch}.${ext}
npmRebuild: false
publish:
provider: github
repo: cherry-studio
owner: kangfenmao
provider: generic
url: https://cherrystudio.ocool.online
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
afterSign: scripts/notarize.js

View File

@ -25,7 +25,7 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: []
exclude: ['chunk-7UIZINC5.js', 'chunk-7OJJKI46.js']
}
}
})

View File

@ -2,6 +2,8 @@ import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater'
import icon from '../../../build/icon.png?asset'
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
@ -43,6 +45,7 @@ export default class AppUpdater {
.showMessageBox({
type: 'info',
title: '安装更新',
icon,
message: `新版本 ${releaseInfo.version} 已准备就绪`,
detail: this.formatReleaseNotes(releaseInfo.releaseNotes),
buttons: ['稍后安装', '立即安装'],

View File

@ -3,6 +3,7 @@ import { FileType } from '@renderer/types'
import { WebDavConfig } from '@renderer/types'
import { AppInfo, LanguageVarious } from '@renderer/types'
import type { OpenDialogOptions } from 'electron'
import type { UpdateInfo } from 'electron-updater'
import { Readable } from 'stream'
declare global {
@ -10,7 +11,7 @@ declare global {
electron: ElectronAPI
api: {
getAppInfo: () => Promise<AppInfo>
checkForUpdate: () => void
checkForUpdate: () => Promise<{ currentVersion: string; updateInfo: UpdateInfo | null }>
openWebsite: (url: string) => void
setProxy: (proxy: string | undefined) => void
setLanguage: (theme: LanguageVarious) => void

View File

@ -0,0 +1,35 @@
// src/renderer/src/components/IndicatorLight.tsx
import React from 'react'
import styled from 'styled-components'
interface IndicatorLightProps {
color: string
}
const Light = styled.div<{ color: string }>`
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${({ color }) => color};
box-shadow: 0 0 6px ${({ color }) => color};
animation: pulse 2s infinite;
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
`
const IndicatorLight: React.FC<IndicatorLightProps> = ({ color }) => {
const actualColor = color === 'green' ? '#22c55e' : color
return <Light color={actualColor} />
}
export default IndicatorLight

View File

@ -3,14 +3,15 @@ import { isLocalAi } from '@renderer/config/env'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { setAvatar, setFilesPath } from '@renderer/store/runtime'
import { runAsyncFunction } from '@renderer/utils'
import { setAvatar, setFilesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
@ -19,6 +20,8 @@ export function useAppInit() {
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
useUpdateHandler()
useEffect(() => {
avatar?.value && dispatch(setAvatar(avatar.value))
}, [avatar, dispatch])
@ -28,11 +31,12 @@ export function useAppInit() {
runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo()
if (isPackaged && !manualUpdateCheck) {
setTimeout(window.api.checkForUpdate, 3000)
await delay(2)
const { updateInfo } = await window.api.checkForUpdate()
dispatch(setUpdateState({ info: updateInfo }))
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [dispatch, manualUpdateCheck])
useEffect(() => {
if (proxyMode === 'system') {

View File

@ -0,0 +1,64 @@
import { useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import type { ProgressInfo, UpdateInfo } from 'electron-updater'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
export default function useUpdateHandler() {
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
const ipcRenderer = window.electron.ipcRenderer
const removers = [
ipcRenderer.on('update-not-available', () => {
dispatch(setUpdateState({ checking: false }))
window.message.success(t('settings.about.updateNotAvailable'))
}),
ipcRenderer.on('update-available', (_, releaseInfo: UpdateInfo) => {
dispatch(
setUpdateState({
checking: false,
downloading: true,
info: releaseInfo,
available: true
})
)
}),
ipcRenderer.on('download-update', () => {
dispatch(
setUpdateState({
checking: false,
downloading: true
})
)
}),
ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => {
dispatch(
setUpdateState({
downloading: progress.percent < 100,
downloadProgress: progress.percent
})
)
}),
ipcRenderer.on('update-downloaded', () => {
dispatch(setUpdateState({ downloading: false }))
}),
ipcRenderer.on('update-error', (_, error) => {
dispatch(
setUpdateState({
checking: false,
downloading: false,
downloadProgress: 0
})
)
window.modal.info({
title: t('settings.about.updateError'),
content: error?.message || t('settings.about.updateError'),
icon: null
})
})
]
return () => removers.forEach((remover) => remover())
}, [dispatch, t])
}

View File

@ -316,6 +316,7 @@
"settings": {
"about": "About & Feedback",
"about.checkUpdate": "Check Update",
"about.checkUpdate.available": "Update",
"about.checkingUpdate": "Checking for updates...",
"about.contact.button": "Email",
"about.contact.title": "Contact",

View File

@ -316,6 +316,7 @@
"settings": {
"about": "О программе и обратная связь",
"about.checkUpdate": "Проверить обновления",
"about.checkUpdate.available": "Обновить",
"about.checkingUpdate": "Проверка обновлений...",
"about.contact.button": "Электронная почта",
"about.contact.title": "Контакты",

View File

@ -316,6 +316,7 @@
"settings": {
"about": "关于我们",
"about.checkUpdate": "检查更新",
"about.checkUpdate.available": "立即更新",
"about.checkingUpdate": "正在检查更新...",
"about.contact.button": "邮件",
"about.contact.title": "邮件联系",

View File

@ -316,6 +316,7 @@
"settings": {
"about": "關於與回饋",
"about.checkUpdate": "檢查更新",
"about.checkUpdate.available": "立即更新",
"about.checkingUpdate": "正在檢查更新...",
"about.contact.button": "郵件",
"about.contact.title": "聯繫方式",

View File

@ -1,15 +1,17 @@
import { GithubOutlined } from '@ant-design/icons'
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout'
import MinApp from '@renderer/components/MinApp'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { setManualUpdateCheck } from '@renderer/store/settings'
import { runAsyncFunction } from '@renderer/utils'
import { Avatar, Button, Progress, Row, Switch, Tag } from 'antd'
import { ProgressInfo, UpdateInfo } from 'electron-updater'
import { debounce } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,17 +24,18 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
const AboutSettings: FC = () => {
const [version, setVersion] = useState('')
const { t } = useTranslation()
const [percent, setPercent] = useState(0)
const [checkUpdateLoading, setCheckUpdateLoading] = useState(false)
const [downloading, setDownloading] = useState(false)
const { manualUpdateCheck } = useSettings()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { update } = useRuntime()
const onCheckUpdate = debounce(
async () => {
if (checkUpdateLoading || downloading) return
setCheckUpdateLoading(true)
if (update.checking || update.downloading) {
return
}
dispatch(setUpdateState({ checking: true }))
try {
await window.api.checkForUpdate()
@ -40,7 +43,7 @@ const AboutSettings: FC = () => {
window.message.error(t('settings.about.updateError'))
}
setCheckUpdateLoading(false)
dispatch(setUpdateState({ checking: false }))
},
2000,
{ leading: true, trailing: false }
@ -75,52 +78,6 @@ const AboutSettings: FC = () => {
})
}, [])
useEffect(() => {
const ipcRenderer = window.electron.ipcRenderer
const removers = [
ipcRenderer.on('update-not-available', () => {
setCheckUpdateLoading(false)
window.message.success(t('settings.about.updateNotAvailable'))
}),
ipcRenderer.on('update-available', (_, releaseInfo: UpdateInfo) => {
setCheckUpdateLoading(false)
setDownloading(true)
window.modal.info({
title: t('settings.about.updateAvailable', { version: releaseInfo.version }),
content: (
<Markdown>
{typeof releaseInfo.releaseNotes === 'string'
? releaseInfo.releaseNotes
: releaseInfo.releaseNotes?.map((note) => note.note).join('\n')}
</Markdown>
)
})
}),
ipcRenderer.on('download-update', () => {
setCheckUpdateLoading(false)
setDownloading(true)
}),
ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => {
setPercent(progress.percent)
setDownloading(progress.percent < 100)
}),
ipcRenderer.on('update-downloaded', () => {
setDownloading(false)
}),
ipcRenderer.on('update-error', (_, error) => {
setCheckUpdateLoading(false)
setDownloading(false)
setPercent(0)
window.modal.info({
title: t('settings.about.updateError'),
content: error?.message || t('settings.about.updateError'),
icon: null
})
})
]
return () => removers.forEach((remover) => remover())
}, [t])
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
@ -136,11 +93,11 @@ const AboutSettings: FC = () => {
<AboutHeader>
<Row align="middle">
<AvatarWrapper onClick={() => onOpenWebsite('https://github.com/kangfenmao/cherry-studio')}>
{percent > 0 && (
{update.downloadProgress > 0 && (
<ProgressCircle
type="circle"
size={84}
percent={percent}
percent={update.downloadProgress}
showInfo={false}
strokeLinecap="butt"
strokeColor="#67ad5b"
@ -161,9 +118,13 @@ const AboutSettings: FC = () => {
</Row>
<CheckUpdateButton
onClick={onCheckUpdate}
loading={checkUpdateLoading}
disabled={downloading || checkUpdateLoading}>
{downloading ? t('settings.about.downloading') : t('settings.about.checkUpdate')}
loading={update.checking}
disabled={update.downloading || update.checking}>
{update.downloading
? t('settings.about.downloading')
: update.available
? t('settings.about.checkUpdate.available')
: t('settings.about.checkUpdate')}
</CheckUpdateButton>
</AboutHeader>
<SettingDivider />
@ -172,6 +133,23 @@ const AboutSettings: FC = () => {
<Switch value={manualUpdateCheck} onChange={(v) => dispatch(setManualUpdateCheck(v))} />
</SettingRow>
</SettingGroup>
{update.info && (
<SettingGroup theme={theme}>
<SettingRow>
<SettingRowTitle>
{t('settings.about.updateAvailable', { version: update.info.version })}
<IndicatorLight color="green" />
</SettingRowTitle>
</SettingRow>
<UpdateNotesWrapper>
<Markdown>
{typeof update.info.releaseNotes === 'string'
? update.info.releaseNotes.replaceAll('\n', '\n\n')
: update.info.releaseNotes?.map((note) => note.note).join('\n')}
</Markdown>
</UpdateNotesWrapper>
</SettingGroup>
)}
<SettingGroup theme={theme}>
<SettingRow>
<SettingRowTitle>
@ -285,4 +263,17 @@ export const SettingRowTitle = styled.div`
}
`
const UpdateNotesWrapper = styled.div`
padding: 12px 0;
margin: 8px 0;
background-color: var(--color-bg-2);
border-radius: 6px;
p {
margin: 0;
color: var(--color-text-2);
font-size: 14px;
}
`
export default AboutSettings

View File

@ -1,5 +1,14 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import type { UpdateInfo } from 'electron-updater'
export interface UpdateState {
info: UpdateInfo | null
checking: boolean
downloading: boolean
downloadProgress: number
available: boolean
}
export interface RuntimeState {
avatar: string
@ -7,6 +16,7 @@ export interface RuntimeState {
minappShow: boolean
searching: boolean
filesPath: string
update: UpdateState
}
const initialState: RuntimeState = {
@ -14,7 +24,14 @@ const initialState: RuntimeState = {
generating: false,
minappShow: false,
searching: false,
filesPath: ''
filesPath: '',
update: {
info: null,
checking: false,
downloading: false,
downloadProgress: 0,
available: false
}
}
const runtimeSlice = createSlice({
@ -35,10 +52,14 @@ const runtimeSlice = createSlice({
},
setFilesPath: (state, action: PayloadAction<string>) => {
state.filesPath = action.payload
},
setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => {
state.update = { ...state.update, ...action.payload }
}
}
})
export const { setAvatar, setGenerating, setMinappShow, setSearching, setFilesPath } = runtimeSlice.actions
export const { setAvatar, setGenerating, setMinappShow, setSearching, setFilesPath, setUpdateState } =
runtimeSlice.actions
export default runtimeSlice.reducer