diff --git a/src/main/index.ts b/src/main/index.ts index 617ff142c..9829e6827 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -644,54 +644,6 @@ async function createWindow(first = true): Promise { return mainWindow?.webContents.session.clearCache(); }); - ipcMain.handle( - 'app-check-for-updates', - async (): Promise<{ updateAvailable: boolean; version?: string }> => { - if (disableAutoUpdates()) { - console.log('Auto updates are disabled'); - return { updateAvailable: false }; - } - - try { - console.log('Checking for updates'); - const effectiveChannel = store.get('release_channel') as string; - let result: null | UpdateCheckResult; - let updater: UpdaterInstance; - - if (effectiveChannel === 'alpha') { - const best = await checkAllChannelsAndGetBest(); - result = best.result; - updater = best.updater; - } else { - updater = configureAndGetUpdater(); - result = await updater.checkForUpdates(); - } - - const updateAvailable = result?.isUpdateAvailable ?? false; - console.log('Update available:', updateAvailable); - if (updateAvailable && store.get('disable_auto_updates') !== true) { - if (isMacOS()) { - getMainWindow()?.webContents.send( - 'update-available', - result?.updateInfo?.version, - ); - } else { - console.log('Downloading update'); - updater.downloadUpdate(); - } - } - - return { - updateAvailable, - version: result?.updateInfo?.version, - }; - } catch { - console.log('Error checking for updates'); - return { updateAvailable: false }; - } - }, - ); - ipcMain.on('app-restart', () => { // Fix for .AppImage if (process.env.APPIMAGE) { diff --git a/src/renderer/features/settings/components/settings-header.tsx b/src/renderer/features/settings/components/settings-header.tsx index adb00d3ad..0afadb68c 100644 --- a/src/renderer/features/settings/components/settings-header.tsx +++ b/src/renderer/features/settings/components/settings-header.tsx @@ -1,6 +1,7 @@ import { closeAllModals, openModal } from '@mantine/modals'; import { useTranslation } from 'react-i18next'; +import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button'; import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; import { SearchInput } from '/@/renderer/features/shared/components/search-input'; @@ -14,9 +15,10 @@ import { Text } from '/@/shared/components/text/text'; export type SettingsHeaderProps = { setSearch: (search: string) => void; + showUpdateAvailable?: boolean; }; -export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => { +export const SettingsHeader = ({ setSearch, showUpdateAvailable }: SettingsHeaderProps) => { const { t } = useTranslation(); const { reset } = useSettingsStoreActions(); const search = useSettingSearchContext(); @@ -48,6 +50,7 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => { + {showUpdateAvailable && } setSearch(event.target.value.toLocaleLowerCase())} diff --git a/src/renderer/features/settings/components/settings-modal.tsx b/src/renderer/features/settings/components/settings-modal.tsx index 6dae672af..8b7dc71c4 100644 --- a/src/renderer/features/settings/components/settings-modal.tsx +++ b/src/renderer/features/settings/components/settings-modal.tsx @@ -9,7 +9,7 @@ export const SettingsContextModal = () => { return ( - + ); diff --git a/src/renderer/features/settings/components/update-available-button.tsx b/src/renderer/features/settings/components/update-available-button.tsx new file mode 100644 index 000000000..b1aea4e25 --- /dev/null +++ b/src/renderer/features/settings/components/update-available-button.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next'; + +import { toTag } from '/@/renderer/hooks'; +import { useLatestVersion } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; + +export const UpdateAvailableButton = () => { + const { t } = useTranslation(); + const { currentVersion, isUpdateAvailable, latestVersion } = useLatestVersion(); + + if (!isUpdateAvailable || !latestVersion) { + return null; + } + + return ( + + ); +}; diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 30c5ff39d..eb09c0e3d 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -4,11 +4,11 @@ import { Fragment, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router'; -import packageJson from '../../../../../package.json'; import styles from './app-menu.module.css'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import { ServerList } from '/@/renderer/features/servers/components/server-list'; +import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button'; import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal'; import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector'; import { openReleaseNotesModal } from '/@/renderer/release-notes-modal'; @@ -18,10 +18,12 @@ import { useCommandPalette, useCurrentServer, useGeneralSettings, + useLatestVersion, useSettingsStoreActions, } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; import { toast } from '/@/shared/components/toast/toast'; @@ -138,6 +140,8 @@ export const AppMenu = () => { }); }; + const { currentVersion } = useLatestVersion(); + const serverHeaderMenuItems: MenuItem[] = currentServer ? [ { @@ -270,10 +274,10 @@ export const AppMenu = () => { { icon: 'brandGitHub', id: 'version', - label: t('page.appMenu.version', { version: packageJson.version }), + label: t('page.appMenu.version', { version: currentVersion }), onClick: () => openReleaseNotesModal( - t('common.newVersion', { version: packageJson.version }) as string, + t('common.newVersion', { version: currentVersion }) as string, ), type: 'item', }, @@ -301,6 +305,15 @@ export const AppMenu = () => { }, type: 'conditional-item', }, + { + component: ( + + + + ), + id: 'update-available', + type: 'custom', + }, { id: 'divider-5', type: 'divider', diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index e083aa1b1..7d79e7534 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -2,6 +2,7 @@ export * from './use-app-focus'; export * from './use-check-for-updates'; export * from './use-container-query'; export * from './use-fast-average-color'; +export * from './use-github-releases'; export * from './use-hide-scrollbar'; export * from './use-hotkeys'; export * from './use-is-mounted'; diff --git a/src/renderer/hooks/use-check-for-updates.ts b/src/renderer/hooks/use-check-for-updates.ts index 6e3f7a239..c7b797483 100644 --- a/src/renderer/hooks/use-check-for-updates.ts +++ b/src/renderer/hooks/use-check-for-updates.ts @@ -1,29 +1,24 @@ -import { useQuery } from '@tanstack/react-query'; -import isElectron from 'is-electron'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { parseVersionFromTag, useGithubLatestRelease } from '/@/renderer/hooks/use-github-releases'; +import { useAppStore } from '/@/renderer/store'; + +// 6 hours const CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000; -const utils = isElectron() ? window.api?.utils : null; - export const useCheckForUpdates = () => { - const [enablePeriodicCheck, setEnablePeriodicCheck] = useState(false); + const setLatestVersion = useAppStore((state) => state.actions.setLatestVersion); - // We want to skip the first check since it's already checked in the main process when the app is started - useEffect(() => { - const timer = setTimeout(() => setEnablePeriodicCheck(true), CHECK_FOR_UPDATES_INTERVAL_MS); - return () => clearTimeout(timer); - }, []); - - const isEnabled = - enablePeriodicCheck && - Boolean(isElectron() && utils?.checkForUpdates && !utils?.disableAutoUpdates?.()); - - return useQuery({ - enabled: isEnabled, - queryFn: () => utils?.checkForUpdates?.(), - queryKey: ['app-check-for-updates'], + const query = useGithubLatestRelease({ refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS, refetchIntervalInBackground: true, }); + + useEffect(() => { + if (query.data) { + setLatestVersion(parseVersionFromTag(query.data.tag_name)); + } + }, [query.data, setLatestVersion]); + + return query; }; diff --git a/src/renderer/hooks/use-github-releases.ts b/src/renderer/hooks/use-github-releases.ts new file mode 100644 index 000000000..55358091b --- /dev/null +++ b/src/renderer/hooks/use-github-releases.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases'; +export const RELEASES_TO_FETCH = 30; + +export interface GitHubRelease { + body: null | string; + name: null | string; + prerelease: boolean; + published_at: string; + tag_name: string; +} + +export function parseVersionFromTag(tagName: string): string { + return tagName.startsWith('v') ? tagName.slice(1) : tagName; +} + +export function toTag(version: string): string { + return version.startsWith('v') ? version : `v${version}`; +} + +export const useGithubReleasesList = (perPage = RELEASES_TO_FETCH) => { + return useQuery({ + queryFn: async () => { + const response = await axios.get(GITHUB_RELEASES_URL, { + params: { per_page: perPage }, + }); + return response.data; + }, + queryKey: ['github-releases-list', perPage], + retry: 2, + }); +}; + +export const useGithubLatestRelease = (options?: { + refetchInterval?: number; + refetchIntervalInBackground?: boolean; +}) => { + return useQuery({ + queryFn: async () => { + const response = await axios.get(`${GITHUB_RELEASES_URL}/latest`); + return response.data; + }, + queryKey: ['github-latest-release'], + retry: 2, + ...options, + }); +}; diff --git a/src/renderer/release-notes-modal.tsx b/src/renderer/release-notes-modal.tsx index 68acba637..6c158e5fe 100644 --- a/src/renderer/release-notes-modal.tsx +++ b/src/renderer/release-notes-modal.tsx @@ -7,6 +7,14 @@ import { useTranslation } from 'react-i18next'; import packageJson from '../../package.json'; +import { + GITHUB_RELEASES_URL, + type GitHubRelease, + parseVersionFromTag, + RELEASES_TO_FETCH, + toTag, + useGithubReleasesList, +} from '/@/renderer/hooks/use-github-releases'; import { formatHrDateTime } from '/@/renderer/utils/format'; import { Button } from '/@/shared/components/button/button'; import { Center } from '/@/shared/components/center/center'; @@ -19,9 +27,7 @@ import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; -const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases'; const GITHUB_COMPARE_URL = 'https://api.github.com/repos/jeffvli/feishin/compare'; -const RELEASES_TO_FETCH = 30; interface GitHubCompareCommit { commit: { @@ -37,14 +43,6 @@ interface GitHubCompareResponse { total_commits: number; } -interface GitHubRelease { - body: null | string; - name: null | string; - prerelease: boolean; - published_at: string; - tag_name: string; -} - interface ReleaseNotesContentProps { onDismiss: () => void; version: string; @@ -54,30 +52,13 @@ function isAlphaVersion(version: string): boolean { return version.includes('-alpha'); } -function parseVersionFromTag(tagName: string): string { - return tagName.startsWith('v') ? tagName.slice(1) : tagName; -} - -function toTag(version: string): string { - return version.startsWith('v') ? version : `v${version}`; -} - const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => { const { t } = useTranslation(); const [selectedVersion, setSelectedVersion] = useState(version); const isAlpha = isAlphaVersion(selectedVersion); // Fetch list of recent releases for the selector - const { data: releasesList = [] } = useQuery({ - queryFn: async () => { - const response = await axios.get(GITHUB_RELEASES_URL, { - params: { per_page: RELEASES_TO_FETCH }, - }); - return response.data; - }, - queryKey: ['github-releases-list'], - retry: 2, - }); + const { data: releasesList = [] } = useGithubReleasesList(); const latestStableRelease = useMemo(() => { return releasesList.find((r) => !r.prerelease); diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 549ced89a..d1b553ae9 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -2,11 +2,15 @@ import type { ItemListStateItem } from '/@/renderer/components/item-list/helpers import type { LibraryItem } from '/@/shared/types/domain-types'; import merge from 'lodash/merge'; +import semverGt from 'semver/functions/gt'; +import semverValid from 'semver/functions/valid'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { shallow } from 'zustand/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; +import packageJson from '../../../package.json'; + import { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { Platform } from '/@/shared/types/types'; @@ -24,6 +28,7 @@ export interface AppSlice extends AppState { setGenreIdsMode: (mode: 'and' | 'or') => void; setGenreSelectMode: (mode: 'multi' | 'single') => void; setGlobalExpanded: (value: GlobalExpandedState | null) => void; + setLatestVersion: (version: null | string) => void; setPageSidebar: (key: string, value: boolean) => void; setPrivateMode: (enabled: boolean) => void; setShowTimeRemaining: (enabled: boolean) => void; @@ -52,6 +57,7 @@ export interface AppState { genreSelectMode: 'multi' | 'single'; globalExpanded: GlobalExpandedState | null; isReorderingQueue: boolean; + latestVersion: null | string; pageSidebar: Record; platform: Platform; privateMode: boolean; @@ -157,6 +163,11 @@ export const useAppStore = createWithEqualityFn()( state.globalExpanded = value; }); }, + setLatestVersion: (version) => { + set((state) => { + state.latestVersion = version; + }); + }, setPageSidebar: (key, value) => { set((state) => { state.pageSidebar[key] = value; @@ -219,6 +230,7 @@ export const useAppStore = createWithEqualityFn()( genreSelectMode: 'multi', globalExpanded: null, isReorderingQueue: false, + latestVersion: null, pageSidebar: { album: true, song: true, @@ -261,7 +273,7 @@ export const useAppStore = createWithEqualityFn()( name: 'store_app', partialize: (state) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state - const { globalExpanded: _, ...rest } = state; + const { globalExpanded: _, latestVersion: __, ...rest } = state; return rest; }, version: 5, @@ -307,6 +319,18 @@ export const useGlobalExpanded = () => useAppStore((state) => state.globalExpand export const useSetGlobalExpanded = () => useAppStore((state) => state.actions.setGlobalExpanded); +export const useLatestVersion = () => { + const latestVersion = useAppStore((state) => state.latestVersion); + const currentVersion = packageJson.version; + const isUpdateAvailable = + !!latestVersion && + !!semverValid(latestVersion) && + !!semverValid(currentVersion) && + semverGt(latestVersion, currentVersion); + + return { currentVersion, isUpdateAvailable, latestVersion }; +}; + export const useGlobalExpandedState = () => { const globalExpanded = useGlobalExpanded(); const setGlobalExpanded = useSetGlobalExpanded();