feat(core): add manual check for updates (#4957)

work for #4523

add `appBuildType` to `runtimeConfig`
add `useAppUpdater` to manage client updates

<!--
copilot:summary
-->
### <samp>🤖[[deprecated]](https://githubnext.com/copilot-for-prs-sunset) Generated by Copilot at cdd012c</samp>

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.

<img width="1073" alt="image" src="https://github.com/toeverything/AFFiNE/assets/102217452/16ae7a6a-0035-4e57-902b-6b8f63169501">
This commit is contained in:
JimmFly 2023-11-29 13:31:25 +00:00
parent 906d224fa9
commit 23518cae16
No known key found for this signature in database
GPG Key ID: 14A6F56854E1BED7
17 changed files with 581 additions and 142 deletions

View File

@ -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<typeof blockSuiteFeatureFlags>;

View File

@ -190,9 +190,17 @@ export interface UpdateMeta {
allowAutoUpdate: boolean;
}
export type UpdaterConfig = {
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
};
export type UpdaterHandlers = {
currentVersion: () => Promise<string>;
quitAndInstall: () => Promise<void>;
downloadUpdate: () => Promise<void>;
getConfig: () => Promise<UpdaterConfig>;
setConfig: (newConfig: Partial<UpdaterConfig>) => Promise<void>;
checkForUpdatesAndNotify: () => Promise<{ version: string } | null>;
};

View File

@ -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<T>,
E extends (callback: (t: T) => void) => () => void,
>(
initialValue: T | null,
{
event,
handler,
onSubscribe,
}: {
event?: E;
handler?: H;
onSubscribe?: () => void;
}
): Observable<T | null> {
return new Observable<T | null>(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<Record<string, boolean>>(
'affine:client-changelog-checked',
{}
);

View File

@ -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 (
<AppUpdaterButtonPure
appQuitting={appQuitting}
updateReady={!!updateReady}
onClickUpdate={onClickUpdate}
onDismissCurrentChangelog={onDismissCurrentChangelog}
onClickUpdate={handleClickUpdate}
onDismissCurrentChangelog={dismissCurrentChangelog}
currentChangelogUnread={currentChangelogUnread}
updateAvailable={updateAvailable}
downloadProgress={downloadProgress}
@ -259,5 +241,3 @@ export function AppUpdaterButton({
/>
);
}
export * from './index.jotai';

View File

@ -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',
},
};

View File

@ -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';

View File

@ -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 (
<>
<SettingHeader
@ -21,26 +46,26 @@ export const AboutAffine = () => {
/>
<SettingWrapper title={t['com.affine.aboutAFFiNE.version.title']()}>
<SettingRow
name={t['com.affine.aboutAFFiNE.version.app']()}
name={appName}
desc={runtimeConfig.appVersion}
/>
className={styles.appImageRow}
>
<img src={appIcon} alt={appName} width={56} height={56} />
</SettingRow>
<SettingRow
name={t['com.affine.aboutAFFiNE.version.editor.title']()}
desc={runtimeConfig.editorVersion}
/>
{runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? (
{environment.isDesktop ? (
<>
<SettingRow
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
desc={t['com.affine.aboutAFFiNE.checkUpdate.description']()}
/>
<UpdateCheckSection />
<SettingRow
name={t['com.affine.aboutAFFiNE.autoCheckUpdate.title']()}
desc={t['com.affine.aboutAFFiNE.autoCheckUpdate.description']()}
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={checked => updateSettings('autoCheckUpdate', checked)}
onChange={onSwitchAutoCheck}
/>
</SettingRow>
<SettingRow
@ -50,8 +75,8 @@ export const AboutAffine = () => {
]()}
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={checked => updateSettings('autoCheckUpdate', checked)}
checked={appSettings.autoDownloadUpdate}
onChange={onSwitchAutoDownload}
/>
</SettingRow>
<SettingRow
@ -69,7 +94,7 @@ export const AboutAffine = () => {
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.contact.title']()}>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://affine.pro"
target="_blank"
@ -78,7 +103,7 @@ export const AboutAffine = () => {
<OpenInNewIcon className="icon" />
</a>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://community.affine.pro"
target="_blank"
@ -88,11 +113,11 @@ export const AboutAffine = () => {
</a>
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.community.title']()}>
<div className={communityWrapper}>
<div className={styles.communityWrapper}>
{relatedLinks.map(({ icon, title, link }) => {
return (
<div
className={communityItem}
className={styles.communityItem}
onClick={() => {
window.open(link, '_blank');
}}
@ -107,7 +132,7 @@ export const AboutAffine = () => {
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.legal.title']()}>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://affine.pro/privacy"
target="_blank"
@ -116,7 +141,7 @@ export const AboutAffine = () => {
<OpenInNewIcon className="icon" />
</a>
<a
className={link}
className={styles.link}
rel="noreferrer"
href="https://affine.pro/terms"
target="_blank"

View File

@ -43,3 +43,37 @@ globalStyle(`${communityItem} p`, {
fontSize: 'var(--affine-font-xs)',
textAlign: 'center',
});
export const checkUpdateDesc = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'flex-start',
selectors: {
'&.active': {
color: 'var(--affine-text-emphasis-color)',
},
'&.error': {
color: 'var(--affine-error-color)',
},
},
});
globalStyle(`${checkUpdateDesc} svg`, {
marginRight: '4px',
});
export const appImageRow = style({
flexDirection: 'row-reverse',
selectors: {
'&.two-col': {
justifyContent: 'flex-end',
},
},
});
globalStyle(`${appImageRow} .right-col`, {
paddingLeft: '0',
paddingRight: '20px',
});

View File

@ -0,0 +1,165 @@
import { SettingRow } from '@affine/component/setting-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import {
downloadProgressAtom,
isCheckingForUpdatesAtom,
updateAvailableAtom,
updateReadyAtom,
useAppUpdater,
} from '@toeverything/hooks/use-app-updater';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo, useState } from 'react';
import { Loading } from '../../../../pure/workspace-slider-bar/workspace-card/loading-icon';
import * as styles from './style.css';
enum CheckUpdateStatus {
UNCHECK = 'uncheck',
LATEST = 'latest',
UPDATE_AVAILABLE = 'update-available',
ERROR = 'error',
}
const useUpdateStatusLabels = (checkUpdateStatus: CheckUpdateStatus) => {
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 (
<span
className={clsx(styles.checkUpdateDesc, {
active:
updateReady ||
(updateAvailable && downloadProgress === null) ||
checkUpdateStatus === CheckUpdateStatus.LATEST,
error: checkUpdateStatus === CheckUpdateStatus.ERROR,
})}
>
{isCheckingForUpdates ? <Loading size={14} /> : null}
{subtitleLabel}
</span>
);
}, [
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>(
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 (
<SettingRow
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
desc={subtitle}
>
<Button
data-testid="check-update-button"
onClick={handleClick}
disabled={downloadProgress !== null && !updateReady}
>
{buttonLabel}
</Button>
</SettingRow>
);
};

View File

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

View File

@ -37,14 +37,14 @@ const schemaToChanel = {
'affine-dev': 'canary', // dev does not have a dedicated app. use canary as the placeholder.
} as Record<Schema, Channel>;
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<Channel, string>;
const appNames = {
export const appNames = {
stable: 'AFFiNE',
canary: 'AFFiNE Canary',
beta: 'AFFiNE Beta',

View File

@ -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<UpdaterConfig> = {}): 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;

View File

@ -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<UpdaterConfig> => {
return getConfig();
},
setConfig: async (
_e: IpcMainInvokeEvent,
newConfig: Partial<UpdaterConfig>
): Promise<void> => {
return setConfig(newConfig);
},
checkForUpdatesAndNotify: async () => {
const res = await checkForUpdates(true);
if (res) {

View File

@ -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"
},

View File

@ -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<T>,
E extends (callback: (t: T) => void) => () => void,
>(
initialValue: T | null,
{
event,
handler,
onSubscribe,
}: {
event?: E;
handler?: H;
onSubscribe?: () => void;
}
): Observable<T | null> {
return new Observable<T | null>(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<UpdateMeta | null>(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<Record<string, boolean>>(
'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,
};
};

View File

@ -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": "Youve 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",

View File

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