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"