diff --git a/dev-app-update.yml b/dev-app-update.yml index 012d0455..12788dcf 100644 --- a/dev-app-update.yml +++ b/dev-app-update.yml @@ -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 diff --git a/electron-builder.yml b/electron-builder.yml index d264fd0a..52b7520a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 338fc001..415b6879 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: [] + exclude: ['chunk-7UIZINC5.js', 'chunk-7OJJKI46.js'] } } }) diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index f8bccfe1..34475289 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -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: ['稍后安装', '立即安装'], diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 0ca94655..3846ac84 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 - checkForUpdate: () => void + checkForUpdate: () => Promise<{ currentVersion: string; updateInfo: UpdateInfo | null }> openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void setLanguage: (theme: LanguageVarious) => void diff --git a/src/renderer/src/components/IndicatorLight.tsx b/src/renderer/src/components/IndicatorLight.tsx new file mode 100644 index 00000000..c7eaa8bd --- /dev/null +++ b/src/renderer/src/components/IndicatorLight.tsx @@ -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 = ({ color }) => { + const actualColor = color === 'green' ? '#22c55e' : color + return +} + +export default IndicatorLight diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 3e111599..3a803f0a 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -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') { diff --git a/src/renderer/src/hooks/useUpdateHandler.ts b/src/renderer/src/hooks/useUpdateHandler.ts new file mode 100644 index 00000000..910e8362 --- /dev/null +++ b/src/renderer/src/hooks/useUpdateHandler.ts @@ -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]) +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f6eb407a..baaf8ffd 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 3e7c9f5e..d180deb1 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -316,6 +316,7 @@ "settings": { "about": "О программе и обратная связь", "about.checkUpdate": "Проверить обновления", + "about.checkUpdate.available": "Обновить", "about.checkingUpdate": "Проверка обновлений...", "about.contact.button": "Электронная почта", "about.contact.title": "Контакты", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9b89665e..4f523de7 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -316,6 +316,7 @@ "settings": { "about": "关于我们", "about.checkUpdate": "检查更新", + "about.checkUpdate.available": "立即更新", "about.checkingUpdate": "正在检查更新...", "about.contact.button": "邮件", "about.contact.title": "邮件联系", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 449587c8..2196abc6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -316,6 +316,7 @@ "settings": { "about": "關於與回饋", "about.checkUpdate": "檢查更新", + "about.checkUpdate.available": "立即更新", "about.checkingUpdate": "正在檢查更新...", "about.contact.button": "郵件", "about.contact.title": "聯繫方式", diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 0716fafb..09a1c1fc 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -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: ( - - {typeof releaseInfo.releaseNotes === 'string' - ? releaseInfo.releaseNotes - : releaseInfo.releaseNotes?.map((note) => note.note).join('\n')} - - ) - }) - }), - 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 ( @@ -136,11 +93,11 @@ const AboutSettings: FC = () => { onOpenWebsite('https://github.com/kangfenmao/cherry-studio')}> - {percent > 0 && ( + {update.downloadProgress > 0 && ( { - {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')} @@ -172,6 +133,23 @@ const AboutSettings: FC = () => { dispatch(setManualUpdateCheck(v))} /> + {update.info && ( + + + + {t('settings.about.updateAvailable', { version: update.info.version })} + + + + + + {typeof update.info.releaseNotes === 'string' + ? update.info.releaseNotes.replaceAll('\n', '\n\n') + : update.info.releaseNotes?.map((note) => note.note).join('\n')} + + + + )} @@ -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 diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 06eb72d3..bcfd0f18 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -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) => { state.filesPath = action.payload + }, + setUpdateState: (state, action: PayloadAction>) => { + 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