mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-24 06:02:51 +03:00
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:
parent
906d224fa9
commit
23518cae16
6
packages/common/env/src/global.ts
vendored
6
packages/common/env/src/global.ts
vendored
@ -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>;
|
||||
|
@ -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>;
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
{}
|
||||
);
|
@ -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';
|
||||
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 {
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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"
|
||||
},
|
||||
|
186
packages/frontend/hooks/src/use-app-updater.ts
Normal file
186
packages/frontend/hooks/src/use-app-updater.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user