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
17 changed files with 581 additions and 142 deletions

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',