{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"