From 23518cae168c688f362b17c7750d3c90aefabb3a Mon Sep 17 00:00:00 2001 From: JimmFly <447268514@qq.com> Date: Wed, 29 Nov 2023 13:31:25 +0000 Subject: [PATCH] feat(core): add manual check for updates (#4957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit work for #4523 add `appBuildType` to `runtimeConfig` add `useAppUpdater` to manage client updates ### 🤖[[deprecated]](https://githubnext.com/copilot-for-prs-sunset) Generated by Copilot at cdd012c This pull request refactors and enhances the update functionality for the frontend. It introduces a new custom hook `useAppUpdater` that simplifies the update logic and state management, and uses it in various components and commands. It also adds more options and feedback for the user to control and monitor the update process, such as manual download, auto-check, and auto-download toggles, and update status and progress indicators. It also updates the `AboutAffine` component to show the app icon, version, and build type. It also adds new translations, dependencies, types, and schemas related to the update functionality. image --- packages/common/env/src/global.ts | 6 + packages/common/infra/src/type.ts | 8 + .../app-updater-button/index.jotai.ts | 69 ------- .../app-sidebar/app-updater-button/index.tsx | 70 +++---- .../frontend/core/.webpack/runtime-config.ts | 4 + .../core/src/commands/affine-updates.tsx | 2 +- .../general-setting/about/index.tsx | 59 ++++-- .../general-setting/about/style.css.ts | 34 ++++ .../about/update-check-section.tsx | 165 ++++++++++++++++ .../src/components/root-app-sidebar/index.tsx | 29 +++ packages/frontend/core/src/pages/open-app.tsx | 4 +- .../src/main/updater/electron-updater.ts | 51 ++++- .../electron/src/main/updater/index.ts | 23 ++- packages/frontend/hooks/package.json | 1 + .../frontend/hooks/src/use-app-updater.ts | 186 ++++++++++++++++++ packages/frontend/i18n/src/resources/en.json | 11 ++ yarn.lock | 1 + 17 files changed, 581 insertions(+), 142 deletions(-) delete mode 100644 packages/frontend/component/src/components/app-sidebar/app-updater-button/index.jotai.ts create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/about/update-check-section.tsx create mode 100644 packages/frontend/hooks/src/use-app-updater.ts diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 7e2125a38e..88c86f24ad 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -39,6 +39,12 @@ export const runtimeFlagsSchema = z.object({ editorFlags: blockSuiteFeatureFlags, appVersion: z.string(), editorVersion: z.string(), + appBuildType: z.union([ + z.literal('stable'), + z.literal('beta'), + z.literal('internal'), + z.literal('canary'), + ]), }); export type BlockSuiteFeatureFlags = z.infer; diff --git a/packages/common/infra/src/type.ts b/packages/common/infra/src/type.ts index cd459f2eca..4467e27734 100644 --- a/packages/common/infra/src/type.ts +++ b/packages/common/infra/src/type.ts @@ -190,9 +190,17 @@ export interface UpdateMeta { allowAutoUpdate: boolean; } +export type UpdaterConfig = { + autoCheckUpdate: boolean; + autoDownloadUpdate: boolean; +}; + export type UpdaterHandlers = { currentVersion: () => Promise; quitAndInstall: () => Promise; + downloadUpdate: () => Promise; + getConfig: () => Promise; + setConfig: (newConfig: Partial) => Promise; checkForUpdatesAndNotify: () => Promise<{ version: string } | null>; }; diff --git a/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.jotai.ts b/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.jotai.ts deleted file mode 100644 index c438a2ce9d..0000000000 --- a/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.jotai.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { isBrowser } from '@affine/env/constant'; -import type { UpdateMeta } from '@toeverything/infra/type'; -import { atomWithObservable, atomWithStorage } from 'jotai/utils'; -import { Observable } from 'rxjs'; - -// todo: move to utils? -function rpcToObservable< - T, - H extends () => Promise, - E extends (callback: (t: T) => void) => () => void, ->( - initialValue: T | null, - { - event, - handler, - onSubscribe, - }: { - event?: E; - handler?: H; - onSubscribe?: () => void; - } -): Observable { - return new Observable(subscriber => { - subscriber.next(initialValue); - onSubscribe?.(); - if (!isBrowser || !environment.isDesktop || !event) { - subscriber.complete(); - return; - } - handler?.() - .then(t => { - subscriber.next(t); - }) - .catch(err => { - subscriber.error(err); - }); - return event(t => { - subscriber.next(t); - }); - }); -} - -export const updateReadyAtom = atomWithObservable(() => { - return rpcToObservable(null as UpdateMeta | null, { - event: window.events?.updater.onUpdateReady, - }); -}); - -export const updateAvailableAtom = atomWithObservable(() => { - return rpcToObservable(null as UpdateMeta | null, { - event: window.events?.updater.onUpdateAvailable, - onSubscribe: () => { - window.apis?.updater.checkForUpdatesAndNotify().catch(err => { - console.error(err); - }); - }, - }); -}); - -export const downloadProgressAtom = atomWithObservable(() => { - return rpcToObservable(null as number | null, { - event: window.events?.updater.onDownloadProgress, - }); -}); - -export const changelogCheckedAtom = atomWithStorage>( - 'affine:client-changelog-checked', - {} -); diff --git a/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.tsx b/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.tsx index a3afe7a00d..4d7a442c81 100644 --- a/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.tsx +++ b/packages/frontend/component/src/components/app-sidebar/app-updater-button/index.tsx @@ -1,18 +1,21 @@ -import { isBrowser, Unreachable } from '@affine/env/constant'; +import { Unreachable } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons'; import { Tooltip } from '@toeverything/components/tooltip'; -import clsx from 'clsx'; -import { atom, useAtomValue, useSetAtom } from 'jotai'; -import { startTransition, useCallback, useState } from 'react'; - -import * as styles from './index.css'; import { changelogCheckedAtom, + currentChangelogUnreadAtom, + currentVersionAtom, downloadProgressAtom, updateAvailableAtom, updateReadyAtom, -} from './index.jotai'; + useAppUpdater, +} from '@toeverything/hooks/use-app-updater'; +import clsx from 'clsx'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { startTransition, useCallback } from 'react'; + +import * as styles from './index.css'; export interface AddPageButtonPureProps { onClickUpdate: () => void; @@ -29,26 +32,6 @@ export interface AddPageButtonPureProps { style?: React.CSSProperties; } -const currentVersionAtom = atom(async () => { - if (!isBrowser) { - return null; - } - const currentVersion = await window.apis?.updater.currentVersion(); - return currentVersion; -}); - -const currentChangelogUnreadAtom = atom(async get => { - if (!isBrowser) { - return false; - } - const mapping = get(changelogCheckedAtom); - const currentVersion = await get(currentVersionAtom); - if (currentVersion) { - return !mapping[currentVersion]; - } - return false; -}); - export function AppUpdaterButtonPure({ updateReady, onClickUpdate, @@ -198,12 +181,12 @@ export function AppUpdaterButton({ const currentChangelogUnread = useAtomValue(currentChangelogUnreadAtom); const updateReady = useAtomValue(updateReadyAtom); const updateAvailable = useAtomValue(updateAvailableAtom); - const currentVersion = useAtomValue(currentVersionAtom); const downloadProgress = useAtomValue(downloadProgressAtom); + const currentVersion = useAtomValue(currentVersionAtom); + const { quitAndInstall, appQuitting } = useAppUpdater(); const setChangelogCheckAtom = useSetAtom(changelogCheckedAtom); - const [appQuitting, setAppQuitting] = useState(false); - const onDismissCurrentChangelog = useCallback(() => { + const dismissCurrentChangelog = useCallback(() => { if (!currentVersion) { return; } @@ -216,13 +199,10 @@ export function AppUpdaterButton({ }) ); }, [currentVersion, setChangelogCheckAtom]); - const onClickUpdate = useCallback(() => { + + const handleClickUpdate = useCallback(() => { if (updateReady) { - setAppQuitting(true); - window.apis?.updater.quitAndInstall().catch(err => { - // TODO: add error toast here - console.error(err); - }); + quitAndInstall(); } else if (updateAvailable) { if (updateAvailable.allowAutoUpdate) { // wait for download to finish @@ -234,23 +214,25 @@ export function AppUpdaterButton({ } } else if (currentChangelogUnread) { window.open(runtimeConfig.changelogUrl, '_blank'); - onDismissCurrentChangelog(); + dismissCurrentChangelog(); } else { throw new Unreachable(); } }, [ - currentChangelogUnread, - currentVersion, - onDismissCurrentChangelog, - updateAvailable, updateReady, + quitAndInstall, + updateAvailable, + currentChangelogUnread, + dismissCurrentChangelog, + currentVersion, ]); + return ( ); } - -export * from './index.jotai'; diff --git a/packages/frontend/core/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts index e54ac2410d..d613fa11aa 100644 --- a/packages/frontend/core/.webpack/runtime-config.ts +++ b/packages/frontend/core/.webpack/runtime-config.ts @@ -38,18 +38,21 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { editorFlags, appVersion: packageJson.version, editorVersion: packageJson.dependencies['@blocksuite/editor'], + appBuildType: 'stable', }, get beta() { return { ...this.stable, enablePageHistory: false, serverUrlPrefix: 'https://insider.affine.pro', + appBuildType: 'beta' as const, }; }, get internal() { return { ...this.stable, serverUrlPrefix: 'https://insider.affine.pro', + appBuildType: 'internal' as const, }; }, // canary will be aggressive and enable all features @@ -82,6 +85,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { editorFlags, appVersion: packageJson.version, editorVersion: packageJson.dependencies['@blocksuite/editor'], + appBuildType: 'canary', }, }; diff --git a/packages/frontend/core/src/commands/affine-updates.tsx b/packages/frontend/core/src/commands/affine-updates.tsx index 4ccc0a4af8..da4e167916 100644 --- a/packages/frontend/core/src/commands/affine-updates.tsx +++ b/packages/frontend/core/src/commands/affine-updates.tsx @@ -1,6 +1,6 @@ -import { updateReadyAtom } from '@affine/component/app-sidebar/app-updater-button'; import type { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ResetIcon } from '@blocksuite/icons'; +import { updateReadyAtom } from '@toeverything/hooks/use-app-updater'; import { registerAffineCommand } from '@toeverything/infra/command'; import type { createStore } from 'jotai'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx index 60e719f685..f25edc5001 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx @@ -4,14 +4,39 @@ import { SettingRow } from '@affine/component/setting-components'; import { SettingWrapper } from '@affine/component/setting-components'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons'; +import { useAppUpdater } from '@toeverything/hooks/use-app-updater'; +import { useCallback } from 'react'; import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting-helper'; +import { appIconMap, appNames } from '../../../../../pages/open-app'; import { relatedLinks } from './config'; -import { communityItem, communityWrapper, link } from './style.css'; +import * as styles from './style.css'; +import { UpdateCheckSection } from './update-check-section'; export const AboutAffine = () => { const t = useAFFiNEI18N(); const { appSettings, updateSettings } = useAppSettingHelper(); + const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater(); + const channel = runtimeConfig.appBuildType; + const appIcon = appIconMap[channel]; + const appName = appNames[channel]; + + const onSwitchAutoCheck = useCallback( + (checked: boolean) => { + toggleAutoCheck(checked); + updateSettings('autoCheckUpdate', checked); + }, + [toggleAutoCheck, updateSettings] + ); + + const onSwitchAutoDownload = useCallback( + (checked: boolean) => { + toggleAutoDownload(checked); + updateSettings('autoDownloadUpdate', checked); + }, + [toggleAutoDownload, updateSettings] + ); + return ( <> { /> + className={styles.appImageRow} + > + {appName} + - {runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? ( + {environment.isDesktop ? ( <> - + updateSettings('autoCheckUpdate', checked)} + onChange={onSwitchAutoCheck} /> { ]()} > updateSettings('autoCheckUpdate', checked)} + checked={appSettings.autoDownloadUpdate} + onChange={onSwitchAutoDownload} /> { { { -
+
{relatedLinks.map(({ icon, title, link }) => { return (
{ window.open(link, '_blank'); }} @@ -107,7 +132,7 @@ export const AboutAffine = () => { { { + const t = useAFFiNEI18N(); + const isCheckingForUpdates = useAtomValue(isCheckingForUpdatesAtom); + const updateAvailable = useAtomValue(updateAvailableAtom); + const updateReady = useAtomValue(updateReadyAtom); + const downloadProgress = useAtomValue(downloadProgressAtom); + + const buttonLabel = useMemo(() => { + if (updateAvailable && downloadProgress === null) { + return t['com.affine.aboutAFFiNE.checkUpdate.button.download'](); + } + if (updateReady) { + return t['com.affine.aboutAFFiNE.checkUpdate.button.restart'](); + } + if ( + checkUpdateStatus === CheckUpdateStatus.LATEST || + checkUpdateStatus === CheckUpdateStatus.ERROR + ) { + return t['com.affine.aboutAFFiNE.checkUpdate.button.retry'](); + } + return t['com.affine.aboutAFFiNE.checkUpdate.button.check'](); + }, [checkUpdateStatus, downloadProgress, t, updateAvailable, updateReady]); + + const subtitleLabel = useMemo(() => { + if (updateAvailable && downloadProgress === null) { + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available']({ + version: updateAvailable.version, + }); + } else if (isCheckingForUpdates) { + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.checking'](); + } else if (updateAvailable && downloadProgress !== null) { + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading'](); + } else if (updateReady) { + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.restart'](); + } else if (checkUpdateStatus === CheckUpdateStatus.ERROR) { + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.error'](); + } else if (checkUpdateStatus === CheckUpdateStatus.LATEST) { + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.latest'](); + } + return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.check'](); + }, [ + checkUpdateStatus, + downloadProgress, + isCheckingForUpdates, + t, + updateAvailable, + updateReady, + ]); + + const subtitle = useMemo(() => { + return ( + + {isCheckingForUpdates ? : null} + {subtitleLabel} + + ); + }, [ + checkUpdateStatus, + downloadProgress, + isCheckingForUpdates, + subtitleLabel, + updateAvailable, + updateReady, + ]); + + return { subtitle, buttonLabel }; +}; + +export const UpdateCheckSection = () => { + const t = useAFFiNEI18N(); + const { checkForUpdates, downloadUpdate, quitAndInstall } = useAppUpdater(); + const updateAvailable = useAtomValue(updateAvailableAtom); + const updateReady = useAtomValue(updateReadyAtom); + const downloadProgress = useAtomValue(downloadProgressAtom); + const [checkUpdateStatus, setCheckUpdateStatus] = useState( + CheckUpdateStatus.UNCHECK + ); + + const { buttonLabel, subtitle } = useUpdateStatusLabels(checkUpdateStatus); + + const asyncCheckForUpdates = useAsyncCallback(async () => { + let statusCheck = CheckUpdateStatus.UNCHECK; + try { + const status = await checkForUpdates(); + + if (status === null) { + statusCheck = CheckUpdateStatus.ERROR; + } else if (status === false) { + statusCheck = CheckUpdateStatus.LATEST; + } else if (typeof status === 'string') { + statusCheck = CheckUpdateStatus.UPDATE_AVAILABLE; + } + } catch (e) { + console.error(e); + statusCheck = CheckUpdateStatus.ERROR; + } finally { + setCheckUpdateStatus(statusCheck); + } + }, [checkForUpdates]); + + const handleClick = useCallback(() => { + if (updateAvailable && downloadProgress === null) { + return downloadUpdate(); + } + if (updateReady) { + return quitAndInstall(); + } + asyncCheckForUpdates(); + }, [ + asyncCheckForUpdates, + downloadProgress, + downloadUpdate, + quitAndInstall, + updateAvailable, + updateReady, + ]); + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 3b82e54e26..e08826e098 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -20,6 +20,11 @@ import type { Page } from '@blocksuite/store'; import { useDroppable } from '@dnd-kit/core'; import { Menu } from '@toeverything/components/menu'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { + isAutoCheckUpdateAtom, + isAutoDownloadUpdateAtom, + useAppUpdater, +} from '@toeverything/hooks/use-app-updater'; import { useAtom, useAtomValue } from 'jotai'; import type { HTMLAttributes, ReactElement } from 'react'; import { forwardRef, useCallback, useEffect, useMemo } from 'react'; @@ -102,6 +107,10 @@ export const RootAppSidebar = ({ }: RootAppSidebarProps): ReactElement => { const currentWorkspaceId = currentWorkspace.id; const { appSettings } = useAppSettingHelper(); + const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater(); + const { autoCheckUpdate, autoDownloadUpdate } = appSettings; + const isAutoDownload = useAtomValue(isAutoDownloadUpdateAtom); + const isAutoCheck = useAtomValue(isAutoCheckUpdateAtom); const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const t = useAFFiNEI18N(); const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom( @@ -150,6 +159,26 @@ export const RootAppSidebar = ({ } }, [sidebarOpen]); + useEffect(() => { + if (!environment.isDesktop) { + return; + } + + if (isAutoCheck !== autoCheckUpdate) { + toggleAutoCheck(autoCheckUpdate); + } + if (isAutoDownload !== autoDownloadUpdate) { + toggleAutoDownload(autoDownloadUpdate); + } + }, [ + autoCheckUpdate, + autoDownloadUpdate, + isAutoCheck, + isAutoDownload, + toggleAutoCheck, + toggleAutoDownload, + ]); + const [history, setHistory] = useHistoryAtom(); const router = useMemo(() => { return { diff --git a/packages/frontend/core/src/pages/open-app.tsx b/packages/frontend/core/src/pages/open-app.tsx index 7e6df768e6..b26da450ff 100644 --- a/packages/frontend/core/src/pages/open-app.tsx +++ b/packages/frontend/core/src/pages/open-app.tsx @@ -37,14 +37,14 @@ const schemaToChanel = { 'affine-dev': 'canary', // dev does not have a dedicated app. use canary as the placeholder. } as Record; -const appIconMap = { +export const appIconMap = { stable: '/imgs/app-icon-stable.ico', canary: '/imgs/app-icon-canary.ico', beta: '/imgs/app-icon-beta.ico', internal: '/imgs/app-icon-internal.ico', } satisfies Record; -const appNames = { +export const appNames = { stable: 'AFFiNE', canary: 'AFFiNE Canary', beta: 'AFFiNE Beta', diff --git a/packages/frontend/electron/src/main/updater/electron-updater.ts b/packages/frontend/electron/src/main/updater/electron-updater.ts index 74559e1a8d..7594664107 100644 --- a/packages/frontend/electron/src/main/updater/electron-updater.ts +++ b/packages/frontend/electron/src/main/updater/electron-updater.ts @@ -18,13 +18,53 @@ export const quitAndInstall = async () => { }; let lastCheckTime = 0; -export const checkForUpdates = async (force = true) => { - // check every 30 minutes (1800 seconds) at most - if (!disabled && (force || lastCheckTime + 1000 * 1800 < Date.now())) { + +let downloading = false; + +export type UpdaterConfig = { + autoCheckUpdate: boolean; + autoDownloadUpdate: boolean; +}; + +const config: UpdaterConfig = { + autoCheckUpdate: true, + autoDownloadUpdate: true, +}; + +export const getConfig = (): UpdaterConfig => { + return { ...config }; +}; + +export const setConfig = (newConfig: Partial = {}): void => { + Object.assign(config, newConfig); +}; + +export const checkForUpdates = async (force = false) => { + if (disabled) { + return; + } + + if ( + force || + (config.autoCheckUpdate && lastCheckTime + 1000 * 1800 < Date.now()) + ) { lastCheckTime = Date.now(); return await autoUpdater.checkForUpdates(); } - return void 0; + return; +}; + +export const downloadUpdate = async () => { + if (disabled) { + return; + } + downloading = true; + autoUpdater.downloadUpdate().catch(e => { + downloading = false; + logger.error('Failed to download update', e); + }); + logger.info('Update available, downloading...'); + return; }; export const registerUpdater = async () => { @@ -60,10 +100,9 @@ export const registerUpdater = async () => { autoUpdater.on('checking-for-update', () => { logger.info('Checking for update'); }); - let downloading = false; autoUpdater.on('update-available', info => { logger.info('Update available', info); - if (allowAutoUpdate && !downloading) { + if (config.autoDownloadUpdate && allowAutoUpdate && !downloading) { downloading = true; autoUpdater?.downloadUpdate().catch(e => { downloading = false; diff --git a/packages/frontend/electron/src/main/updater/index.ts b/packages/frontend/electron/src/main/updater/index.ts index 9a7d4378bc..19d24aaaf3 100644 --- a/packages/frontend/electron/src/main/updater/index.ts +++ b/packages/frontend/electron/src/main/updater/index.ts @@ -1,7 +1,14 @@ -import { app } from 'electron'; +import { app, type IpcMainInvokeEvent } from 'electron'; import type { NamespaceHandlers } from '../type'; -import { checkForUpdates, quitAndInstall } from './electron-updater'; +import { + checkForUpdates, + downloadUpdate, + getConfig, + quitAndInstall, + setConfig, + type UpdaterConfig, +} from './electron-updater'; export const updaterHandlers = { currentVersion: async () => { @@ -10,6 +17,18 @@ export const updaterHandlers = { quitAndInstall: async () => { return quitAndInstall(); }, + downloadUpdate: async () => { + return downloadUpdate(); + }, + getConfig: async (): Promise => { + return getConfig(); + }, + setConfig: async ( + _e: IpcMainInvokeEvent, + newConfig: Partial + ): Promise => { + return setConfig(newConfig); + }, checkForUpdatesAndNotify: async () => { const res = await checkForUpdates(true); if (res) { diff --git a/packages/frontend/hooks/package.json b/packages/frontend/hooks/package.json index e409597eef..8790d9f708 100644 --- a/packages/frontend/hooks/package.json +++ b/packages/frontend/hooks/package.json @@ -12,6 +12,7 @@ "lodash.debounce": "^4.0.8", "p-queue": "^7.4.1", "react": "18.2.0", + "rxjs": "^7.8.1", "swr": "2.2.4", "uuid": "^9.0.1" }, diff --git a/packages/frontend/hooks/src/use-app-updater.ts b/packages/frontend/hooks/src/use-app-updater.ts new file mode 100644 index 0000000000..adf0c8e7f0 --- /dev/null +++ b/packages/frontend/hooks/src/use-app-updater.ts @@ -0,0 +1,186 @@ +import { isBrowser } from '@affine/env/constant'; +import type { UpdateMeta } from '@toeverything/infra/type'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { atomWithObservable, atomWithStorage } from 'jotai/utils'; +import { useCallback, useState } from 'react'; +import { Observable } from 'rxjs'; + +function rpcToObservable< + T, + H extends () => Promise, + E extends (callback: (t: T) => void) => () => void, +>( + initialValue: T | null, + { + event, + handler, + onSubscribe, + }: { + event?: E; + handler?: H; + onSubscribe?: () => void; + } +): Observable { + return new Observable(subscriber => { + subscriber.next(initialValue); + onSubscribe?.(); + if (!isBrowser || !environment.isDesktop || !event) { + subscriber.complete(); + return; + } + handler?.() + .then(t => { + subscriber.next(t); + }) + .catch(err => { + subscriber.error(err); + }); + return event(t => { + subscriber.next(t); + }); + }); +} + +export const updateReadyAtom = atomWithObservable(() => { + return rpcToObservable(null as UpdateMeta | null, { + event: window.events?.updater.onUpdateReady, + }); +}); + +export const updateAvailableStateAtom = atom(null); + +export const updateAvailableAtom = atomWithObservable(get => { + return rpcToObservable(get(updateAvailableStateAtom), { + event: window.events?.updater.onUpdateAvailable, + onSubscribe: () => { + window.apis?.updater.checkForUpdatesAndNotify().catch(err => { + console.error(err); + }); + }, + }); +}); + +export const downloadProgressAtom = atomWithObservable(() => { + return rpcToObservable(null as number | null, { + event: window.events?.updater.onDownloadProgress, + }); +}); + +export const changelogCheckedAtom = atomWithStorage>( + 'affine:client-changelog-checked', + {} +); + +export const currentVersionAtom = atom(async () => { + if (!isBrowser) { + return null; + } + const currentVersion = await window.apis?.updater.currentVersion(); + return currentVersion; +}); + +export const currentChangelogUnreadAtom = atom(async get => { + if (!isBrowser) { + return false; + } + const mapping = get(changelogCheckedAtom); + const currentVersion = await get(currentVersionAtom); + if (currentVersion) { + return !mapping[currentVersion]; + } + return false; +}); + +export const isCheckingForUpdatesAtom = atom(false); +export const isAutoDownloadUpdateAtom = atom(true); +export const isAutoCheckUpdateAtom = atom(true); + +export const useAppUpdater = () => { + const [appQuitting, setAppQuitting] = useState(false); + const updateReady = useAtomValue(updateReadyAtom); + const setUpdateAvailableState = useSetAtom(updateAvailableStateAtom); + const setIsCheckingForUpdates = useSetAtom(isCheckingForUpdatesAtom); + const setIsAutoCheckUpdate = useSetAtom(isAutoCheckUpdateAtom); + const setIsAutoDownloadUpdate = useSetAtom(isAutoDownloadUpdateAtom); + + const quitAndInstall = useCallback(() => { + if (updateReady) { + setAppQuitting(true); + window.apis?.updater.quitAndInstall().catch(err => { + // TODO: add error toast here + console.error(err); + }); + } + }, [updateReady]); + + const checkForUpdates = useCallback(async () => { + setIsCheckingForUpdates(true); + try { + const updateInfo = await window.apis?.updater.checkForUpdatesAndNotify(); + setIsCheckingForUpdates(false); + if (updateInfo) { + const updateMeta: UpdateMeta = { + version: updateInfo.version, + allowAutoUpdate: false, + }; + setUpdateAvailableState(updateMeta); + return updateInfo.version; + } + return false; + } catch (err) { + setIsCheckingForUpdates(false); + console.error('Error checking for updates:', err); + return null; + } + }, [setIsCheckingForUpdates, setUpdateAvailableState]); + + const downloadUpdate = useCallback(() => { + window.apis?.updater + .downloadUpdate() + .then(() => {}) + .catch(err => { + console.error('Error downloading update:', err); + }); + }, []); + + const toggleAutoDownload = useCallback( + (enable: boolean) => { + window.apis?.updater + .setConfig({ + autoDownloadUpdate: enable, + }) + .then(() => { + setIsAutoDownloadUpdate(enable); + }) + .catch(err => { + console.error('Error setting auto download:', err); + }); + }, + [setIsAutoDownloadUpdate] + ); + + const toggleAutoCheck = useCallback( + (enable: boolean) => { + window.apis?.updater + .setConfig({ + autoCheckUpdate: enable, + }) + .then(() => { + setIsAutoCheckUpdate(enable); + }) + .catch(err => { + console.error('Error setting auto check:', err); + }); + }, + [setIsAutoCheckUpdate] + ); + + return { + quitAndInstall, + appQuitting, + checkForUpdates, + downloadUpdate, + toggleAutoDownload, + toggleAutoCheck, + }; +}; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 0af0b9fc35..bbbb19b8c1 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -346,6 +346,17 @@ "com.affine.aboutAFFiNE.changelog.title": "Discover what's new", "com.affine.aboutAFFiNE.checkUpdate.description": "New version is ready", "com.affine.aboutAFFiNE.checkUpdate.title": "Check for updates", + "com.affine.aboutAFFiNE.checkUpdate.button.check": "Check for Update", + "com.affine.aboutAFFiNE.checkUpdate.button.download": "Download Update", + "com.affine.aboutAFFiNE.checkUpdate.button.restart": "Restart to Update", + "com.affine.aboutAFFiNE.checkUpdate.button.retry": "Retry", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.check": "Manually check for updates.", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.checking": "Checking for updates...", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available": "New update available ({{version}})", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading": "Downloading the latest version...", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart tot apply update.", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "You’ve got the latest version of AFFiNE.", + "com.affine.aboutAFFiNE.checkUpdate.subtitle.error": "Unable to connect to the update server.", "com.affine.aboutAFFiNE.community.title": "Communities", "com.affine.aboutAFFiNE.contact.community": "AFFiNE Community", "com.affine.aboutAFFiNE.contact.title": "Contact Us", diff --git a/yarn.lock b/yarn.lock index 1082ad531c..b5355fd19b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13065,6 +13065,7 @@ __metadata: lodash.debounce: "npm:^4.0.8" p-queue: "npm:^7.4.1" react: "npm:18.2.0" + rxjs: "npm:^7.8.1" swr: "npm:2.2.4" uuid: "npm:^9.0.1" vitest: "npm:0.34.6"