From f522554d95e6d040acfa786362ad6485befdf598 Mon Sep 17 00:00:00 2001 From: Matthew Scharley Date: Wed, 16 Mar 2022 01:32:51 +1100 Subject: [PATCH] feat: add better notifications of available updates (#110) * feat: add better notifications of available updates * feat: allow for manual update checks --- .env.example | 3 + .gitignore | 1 + package.json | 2 +- src/main/Entrypoint.ts | 3 +- src/main/MainWindow.ts | 4 ++ src/main/preload.ts | 1 + src/main/services/AboutElectron.ts | 8 +-- src/main/services/Configuration.ts | 2 +- src/main/services/FileSystem.ts | 2 +- src/main/services/UpdatesProvider.ts | 71 ++++++++++++++++++- src/main/services/__tests__/AboutElectron.ts | 58 --------------- src/renderer/DataProvider.tsx | 36 +++++++--- src/renderer/components/AboutDialog.tsx | 23 +++++- .../components/NotificationsOverlay.tsx | 11 ++- .../components/SpinningRefreshIcon.tsx | 39 ++++++++++ .../components/sidebar/SidebarFooter.tsx | 28 ++------ src/renderer/redux/about/details-slice.ts | 6 +- src/renderer/redux/index.ts | 1 + src/renderer/redux/reducer.ts | 2 + src/renderer/redux/updates/update-slice.ts | 22 ++++++ src/shared/model/AboutDetails.ts | 3 - src/shared/model/UpdateStatus.ts | 7 ++ src/shared/model/index.ts | 1 + src/shared/renderer-api.d.ts | 15 +++- 24 files changed, 233 insertions(+), 116 deletions(-) delete mode 100644 src/main/services/__tests__/AboutElectron.ts create mode 100644 src/renderer/components/SpinningRefreshIcon.tsx create mode 100644 src/renderer/redux/updates/update-slice.ts create mode 100644 src/shared/model/UpdateStatus.ts diff --git a/.env.example b/.env.example index 5da2f248..1e4bbf28 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ # If this is set to any value then it will force update checks in local development. # FORCE_CHECK_UPDATES=true + +# Uncomment this if you want to use a local wine prefix for building Windows packages. +# WINEPREFIX=./.wine diff --git a/.gitignore b/.gitignore index 063d05e6..ec2135f1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ dev-app-update.yml .env *.tsbuildinfo +.wine/ diff --git a/package.json b/package.json index 79e8942d..f7201a02 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test:build": "electron-builder --linux --publish never && ./dist/Notes-${npm_package_version}.AppImage --self-test", "viteshot": "viteshot", "viteshot:ci": "node ./bin/viteshot-ci.mjs", - "pack": "npm run release -- --publish never", + "pack": "set -a; [ -f ./.env ] && source ./.env; npm run release -- --publish never", "release": "electron-builder --win --linux" }, "watch": { diff --git a/src/main/Entrypoint.ts b/src/main/Entrypoint.ts index 93696813..d94d3c29 100644 --- a/src/main/Entrypoint.ts +++ b/src/main/Entrypoint.ts @@ -37,8 +37,7 @@ export class Entrypoint { this.application.on('second-instance', this.onSecondInstance); this.application.on('window-all-closed', this.onWindowAllClosed); - // The ready handler should be the last registered as it may be fired immediately. - this.application.on('ready', this.onReady); + this.application.whenReady().then(this.onReady).catch(this.onFatalError); }; private readonly onSecondInstance = ( diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 4fb34d74..38afdde6 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -85,6 +85,10 @@ export class MainWindow implements OnReadyHandler { } }; + public readonly send = (channel: string, ...args: unknown[]): void => { + this.window?.webContents.send(channel, ...args); + }; + private readonly isAllowedInternalNavigationUrl = (url: string): boolean => { const parsed = new URL(url); diff --git a/src/main/preload.ts b/src/main/preload.ts index 9e18f65d..555f50c0 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -34,6 +34,7 @@ class EditorApiImpl implements EditorApi { public readonly aboutDetails: EditorApi['aboutDetails'] = ipcRenderer.invoke('about-details'); public readonly addFolder: EditorApi['addFolder'] = async (name: string, localPath: string) => ipcRenderer.invoke('add-folder', name, localPath); + public readonly checkForUpdates: EditorApi['checkForUpdates'] = () => ipcRenderer.send('check-updates'); public readonly deleteFolder: EditorApi['deleteFolder'] = async (uuid: string) => ipcRenderer.invoke('delete-folder', uuid); public readonly doLinuxInstallation: EditorApi['doLinuxInstallation'] = async (options) => diff --git a/src/main/services/AboutElectron.ts b/src/main/services/AboutElectron.ts index 1c9a7c22..11fa4914 100644 --- a/src/main/services/AboutElectron.ts +++ b/src/main/services/AboutElectron.ts @@ -1,25 +1,22 @@ import { ElectronApp, ElectronIpcMain } from '~main/inversify/tokens'; import { inject, injectable, unmanaged } from 'inversify'; import type { AboutDetails } from '~shared/model/AboutDetails'; -import { compare } from 'compare-versions'; import { DevTools } from '~main/services/DevTools'; import { injectToken } from 'inversify-token'; import type { OnReadyHandler } from '~main/interfaces/OnReadyHandler'; -import { UpdatesProvider } from '~main/services/UpdatesProvider'; @injectable() export class AboutElectron implements OnReadyHandler { public constructor( @injectToken(ElectronApp) private readonly app: ElectronApp, @injectToken(ElectronIpcMain) private readonly ipcMain: ElectronIpcMain, - @inject(UpdatesProvider) private readonly updates: UpdatesProvider, @inject(DevTools) private readonly devtools: DevTools, @unmanaged() private readonly process: NodeJS.Process = globalThis.process, ) {} public readonly onAppReady = (): void => { + // eslint-disable-next-line @typescript-eslint/require-await this.ipcMain.handle('about-details', async (): Promise => { - const updateCheckResults = await this.updates.checkResults; const version = this.app.getVersion(); return { @@ -27,9 +24,6 @@ export class AboutElectron implements OnReadyHandler { version, isDevBuild: this.devtools.isDev, - updateExists: compare(updateCheckResults?.updateInfo.version ?? '0.0.0', version, '>'), - updateVersion: updateCheckResults?.updateInfo.version, - osName: this.process.platform, osVersion: this.process.getSystemVersion(), }; diff --git a/src/main/services/Configuration.ts b/src/main/services/Configuration.ts index 3100956b..47c7d3da 100644 --- a/src/main/services/Configuration.ts +++ b/src/main/services/Configuration.ts @@ -73,7 +73,7 @@ export class Configuration implements ReadyHandler { this.ipcMain.handle('set-last-folder', (_ev, uuid: string) => this.store.set('lastFolder', uuid)); this.ipcMain.handle('set-last-file', (_ev, url: string) => this.store.set('lastFile', url)); this.onChange(() => { - this.window.window?.webContents.send('configuration', this.asAppConfiguration()); + this.window.send('configuration', this.asAppConfiguration()); }); }; diff --git a/src/main/services/FileSystem.ts b/src/main/services/FileSystem.ts index a39fe7ce..b4b5e391 100644 --- a/src/main/services/FileSystem.ts +++ b/src/main/services/FileSystem.ts @@ -224,7 +224,7 @@ export class FileSystem implements CustomProtocolProvider, OnReadyHandler { private readonly republishFileList = async (): Promise => { const files = await this.listFiles(); - this.mainWindow.window?.webContents.send('files-updated', files); + this.mainWindow.send('files-updated', files); }; private readonly serveLocalFile = (basepath: string, filepath: string, url: string): string | ProtocolResponse => { diff --git a/src/main/services/UpdatesProvider.ts b/src/main/services/UpdatesProvider.ts index 15b575bb..d2551615 100644 --- a/src/main/services/UpdatesProvider.ts +++ b/src/main/services/UpdatesProvider.ts @@ -2,13 +2,22 @@ import type { AppUpdater, UpdateCheckResult } from 'electron-updater'; import { inject, injectable, unmanaged } from 'inversify'; import { autoUpdater } from 'electron-updater'; import { DevTools } from '~main/services/DevTools'; +import { ElectronIpcMain } from '~main/inversify/tokens'; +import { injectToken } from 'inversify-token'; import log from 'electron-log'; +import { MainWindow } from '~main/MainWindow'; import type { OnReadyHandler } from '~main/interfaces/OnReadyHandler'; +import type { UpdateStatus } from '~shared/model'; + +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +export const ONE_WEEK_IN_MILLISECONDS = 7 * 24 * 60 * 60 * 1000; @injectable() export class UpdatesProvider implements OnReadyHandler { public constructor( @inject(DevTools) private readonly devtools: DevTools, + @inject(MainWindow) private readonly mainWindow: MainWindow, + @injectToken(ElectronIpcMain) private readonly ipcMain: ElectronIpcMain, @unmanaged() private readonly updater: AppUpdater = autoUpdater, ) { this.updater.logger = log; @@ -20,13 +29,69 @@ export class UpdatesProvider implements OnReadyHandler { } public readonly onAppReady = (): void => { + this.updateStatus({ + canCheckForUpdates: false, + checkingForUpdate: false, + updateDownloaded: false, + updateExists: false, + }); // TODO: make this configurable. this.updater.autoDownload = true; if (!this.devtools.isDev || 'FORCE_CHECK_UPDATES' in process.env) { - this._checkResults = this.updater.checkForUpdates().catch((_e) => { - // Don't both logging here, electron-updater already logs the error. + this.ipcMain.on('check-updates', this.doSingleUpdateRun); + setInterval(this.doSingleUpdateRun, ONE_WEEK_IN_MILLISECONDS); + } + }; + + private readonly doSingleUpdateRun = (): void => { + this.updateStatus({ + canCheckForUpdates: true, + checkingForUpdate: true, + updateDownloaded: false, + updateExists: false, + }); + this._checkResults = this.updater + .checkForUpdates() + .then((update) => { + if (update.downloadPromise != null) { + this.updateStatus({ + canCheckForUpdates: true, + checkingForUpdate: false, + updateDownloaded: false, + updateExists: true, + updateVersion: update.updateInfo.version, + }); + update.downloadPromise + .then(() => { + this.updateStatus({ + canCheckForUpdates: true, + checkingForUpdate: false, + updateDownloaded: true, + updateExists: true, + updateVersion: update.updateInfo.version, + }); + }) + .catch((e) => { + log.error(e); + }); + } else { + this.updateStatus({ + canCheckForUpdates: true, + checkingForUpdate: false, + updateDownloaded: false, + updateExists: false, + }); + } + + return update; + }) + .catch((_e) => { + // Don't bother logging here, electron-updater already logs the error. return null; }); - } + }; + + private readonly updateStatus = (status: UpdateStatus): void => { + this.mainWindow.send('update-status', status); }; } diff --git a/src/main/services/__tests__/AboutElectron.ts b/src/main/services/__tests__/AboutElectron.ts deleted file mode 100644 index 4281a364..00000000 --- a/src/main/services/__tests__/AboutElectron.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as td from 'testdouble'; -import type { ElectronApp, ElectronIpcMain } from '~main/inversify/tokens'; -import type { AboutDetails } from '~shared/model/AboutDetails'; -import { AboutElectron } from '~main/services/AboutElectron'; -import type { DevTools } from '~main/services/DevTools'; -import type { Mutable } from '~shared/util'; -import type { UpdateCheckResult } from 'electron-updater'; -import type { UpdatesProvider } from '~main/services/UpdatesProvider'; - -describe('AboutElectron', () => { - const app: ElectronApp = td.object('app'); - const ipcMain: ElectronIpcMain = td.object('ipcMain'); - const updates: UpdatesProvider = td.object('updates'); - const devtools: DevTools = td.object('devtools'); - const process: NodeJS.Process = td.object('process'); - let about: AboutElectron; - let aboutDetails: () => Promise; - - beforeEach(() => { - about = new AboutElectron(app, ipcMain, updates, devtools, process); - td.when(app.getVersion()).thenReturn('1.2.3'); - const aboutCaptor = td.matchers.captor(); - td.when(ipcMain.handle('about-details', aboutCaptor.capture())).thenReturn(undefined); - about.onAppReady(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - aboutDetails = aboutCaptor.value; - }); - - afterEach(() => { - td.reset(); - }); - - it('returns no update if the latest update is the same as the current version', async () => { - (updates as Mutable).checkResults = Promise.resolve({ - updateInfo: { - version: '1.2.3', - }, - } as Partial as UpdateCheckResult); - - expect(await aboutDetails()).toMatchObject({ - updateExists: false, - updateVersion: '1.2.3', - }); - }); - - it('returns updates if the latest update is newer than the current version', async () => { - (updates as Mutable).checkResults = Promise.resolve({ - updateInfo: { - version: '2.0.0', - }, - } as Partial as UpdateCheckResult); - - expect(await aboutDetails()).toMatchObject({ - updateExists: true, - updateVersion: '2.0.0', - }); - }); -}); diff --git a/src/renderer/DataProvider.tsx b/src/renderer/DataProvider.tsx index cd44ce52..74ce5897 100644 --- a/src/renderer/DataProvider.tsx +++ b/src/renderer/DataProvider.tsx @@ -5,6 +5,7 @@ import { setCurrentFolder, setFatalError, setFileListing, + setUpdateStatus, updateAppConfiguration, } from '~renderer/redux'; import { useAppDispatch, useAppSelector } from '~renderer/hooks'; @@ -50,22 +51,41 @@ export const DataProvider: React.FC = ({ children }) => { }, [dispatch, handleFileUpdates]); useEffect(() => { - editorApi.aboutDetails - .then((details) => dispatch(setAboutDetails(details))) - .catch((e) => dispatch(setFatalError(e))); - }, [dispatch]); - - useEffect(() => { - editorApi + const result = editorApi .getAppConfiguration() .then((configuration) => { setLoadedConfiguration(true); - editorApi.on('configuration', (newConfiguration) => { + const handler = editorApi.on('configuration', (newConfiguration) => { dispatch(updateAppConfiguration(newConfiguration)); }); dispatch(updateAppConfiguration(configuration)); + + return handler; }) .catch((e) => dispatch(setFatalError(e))); + + return (): void => { + result + .then((v) => (typeof v === 'number' ? editorApi.off('configuration', v) : undefined)) + .catch((e) => dispatch(setFatalError(e))); + }; + }, [dispatch]); + + useEffect(() => { + const handler = editorApi.on('update-status', (status) => { + dispatch(setUpdateStatus(status)); + }); + editorApi.checkForUpdates(); + + return (): void => { + editorApi.off('update-status', handler); + }; + }, [dispatch]); + + useEffect(() => { + editorApi.aboutDetails + .then((details) => dispatch(setAboutDetails(details))) + .catch((e) => dispatch(setFatalError(e))); }, [dispatch]); useEffect(() => { diff --git a/src/renderer/components/AboutDialog.tsx b/src/renderer/components/AboutDialog.tsx index 40b0811f..e8770629 100644 --- a/src/renderer/components/AboutDialog.tsx +++ b/src/renderer/components/AboutDialog.tsx @@ -4,7 +4,9 @@ import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; +import IconButton from '@mui/material/IconButton'; import { noop } from '~shared/util'; +import { SpinningRefreshIcon } from './SpinningRefreshIcon'; import Typography from '@mui/material/Typography'; import { useAppSelector } from '~renderer/hooks'; @@ -15,6 +17,12 @@ export interface AboutDialogProps { export const AboutDialog: React.FC = ({ open, onClose = noop }) => { const details = useAppSelector((s) => s.about.details); + const updates = useAppSelector((s) => s.updates.status) ?? { + canCheckForUpdates: false, + checkingForUpdate: false, + updateDownloaded: false, + updateExists: false, + }; return ( @@ -27,7 +35,20 @@ export const AboutDialog: React.FC = ({ open, onClose = noop } Version: {` ${details.version}${details.isDevBuild ? ' (dev)' : ''}`} - {`${details.updateExists ? ` - update to ${details.updateVersion} by restarting the application.` : ''}`} + {updates.updateDownloaded ? ( + <> - update to {updates.updateVersion} by restarting the application. + ) : updates.updateExists ? ( + <> - update to {updates.updateVersion} downloading... + ) : updates.canCheckForUpdates ? ( + <> + editorApi.checkForUpdates()}> + + + Check for updates + + ) : ( + '' + )}
Electron Version: {` ${details.electronVersion}`} diff --git a/src/renderer/components/NotificationsOverlay.tsx b/src/renderer/components/NotificationsOverlay.tsx index bbfb9e3c..bcd3b1ff 100644 --- a/src/renderer/components/NotificationsOverlay.tsx +++ b/src/renderer/components/NotificationsOverlay.tsx @@ -1,8 +1,10 @@ import { useAppSelector, useDebouncedState } from '~renderer/hooks'; +import Autorenew from '@mui/icons-material/Autorenew'; import Box from '@mui/material/Box'; import type { BoxProps } from '@mui/system/Box'; import SaveAsSharp from '@mui/icons-material/SaveAsSharp'; import { styled } from '@mui/material'; +import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { useEffect } from 'react'; @@ -20,7 +22,6 @@ const NotificationsBox = styled(Box)((context) => ({ borderLeft: '1px solid', borderColor: context.theme.palette.text.disabled, borderRadius: '3px 0 0 0', - pointerEvents: 'none', background: context.theme.palette.background.paper, })); @@ -29,6 +30,7 @@ const fontSize = 'small'; export const NotificationsOverlay: React.FC = () => { const isDev = useAppSelector((s) => s.about.details?.isDevBuild) ?? false; const notificationsState = useAppSelector((s) => s.notifications); + const updateDownloaded = useAppSelector((s) => s.updates.status?.updateDownloaded ?? false); const [saving, setSaving, flushSaving] = useDebouncedState(false, 2_000); useEffect(() => { @@ -44,8 +46,13 @@ export const NotificationsOverlay: React.FC = () => {
) : null; const saveIcon = saving ? : null; + const updateAvailable = updateDownloaded ? ( + + + + ) : null; - const notifications = [devBuild, saveIcon].filter((x) => x != null); + const notifications = [devBuild, saveIcon, updateAvailable].filter((x) => x != null); return notifications.length > 0 ? {notifications} : <>; }; diff --git a/src/renderer/components/SpinningRefreshIcon.tsx b/src/renderer/components/SpinningRefreshIcon.tsx new file mode 100644 index 00000000..5615c22a --- /dev/null +++ b/src/renderer/components/SpinningRefreshIcon.tsx @@ -0,0 +1,39 @@ +import cn from 'classnames'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { styled } from '@mui/material'; +import { useDebouncedState } from '~renderer/hooks'; +import { useEffect } from 'react'; + +const Icon = styled(RefreshIcon)` + &.spin { + animation-name: spin; + animation-duration: 1000ms; + animation-timing-function: linear; + } + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +`; + +export interface SpinningRefreshIconProps { + spinning: boolean; + minimumSpinTime?: number; +} + +export const SpinningRefreshIcon: React.FC = ({ spinning, minimumSpinTime = 1_000 }) => { + const [spinningState, setSpinning, flushSpinning] = useDebouncedState(false, minimumSpinTime); + + useEffect(() => { + setSpinning(spinning); + if (spinning) { + flushSpinning(); + } + }, [spinning, setSpinning, flushSpinning]); + + return ; +}; diff --git a/src/renderer/components/sidebar/SidebarFooter.tsx b/src/renderer/components/sidebar/SidebarFooter.tsx index 261946a3..f8eb0925 100644 --- a/src/renderer/components/sidebar/SidebarFooter.tsx +++ b/src/renderer/components/sidebar/SidebarFooter.tsx @@ -1,42 +1,24 @@ import { closeCurrentFile, setActiveOverlay, setFatalError, setFileListing } from '~renderer/redux'; -import { useAppDispatch, useAppSelector, useDebouncedState } from '~renderer/hooks'; -import cn from 'classnames'; +import { useAppDispatch, useAppSelector } from '~renderer/hooks'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; -import RefreshIcon from '@mui/icons-material/Refresh'; import SettingsIcon from '@mui/icons-material/SettingsSharp'; -import { styled } from '@mui/material'; +import { SpinningRefreshIcon } from '../SpinningRefreshIcon'; import Tooltip from '@mui/material/Tooltip'; +import { useState } from 'react'; export interface SidebarFooterProps { width: string; } -const SpinningRefreshIcon = styled(RefreshIcon)` - &.spin { - animation-name: spin; - animation-duration: 1s; - animation-timing-function: linear; - } - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } -`; - export const SidebarFooter: React.FC = ({ width }) => { const dispatch = useAppDispatch(); + const [spinning, setSpinning] = useState(false); const currentFile = useAppSelector((s) => s.files.currentFile); - const [spinning, setSpinning, flushSpinning] = useDebouncedState(false, 1_000); const handleRefresh: React.MouseEventHandler = () => { setSpinning(true); - flushSpinning(); editorApi .listNoteFiles() @@ -94,7 +76,7 @@ export const SidebarFooter: React.FC = ({ width }) => { - + diff --git a/src/renderer/redux/about/details-slice.ts b/src/renderer/redux/about/details-slice.ts index f3396a4c..ac3ab9dc 100644 --- a/src/renderer/redux/about/details-slice.ts +++ b/src/renderer/redux/about/details-slice.ts @@ -1,10 +1,10 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; -import type { AboutDetails } from '~shared/model/AboutDetails'; +import type { AboutDetails } from '~shared/model'; -export type AboutSlice = { loading: false; details?: undefined } | { loading: true; details: AboutDetails }; +export type AboutSlice = { loading: true; details?: undefined } | { loading: false; details: AboutDetails }; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -const initialState = { loading: false } as AboutSlice; +const initialState = { loading: true } as AboutSlice; export const setAboutDetails = createAction('setAboutDetails'); diff --git a/src/renderer/redux/index.ts b/src/renderer/redux/index.ts index 24f3abf2..817465ab 100644 --- a/src/renderer/redux/index.ts +++ b/src/renderer/redux/index.ts @@ -8,3 +8,4 @@ export { setFatalError } from './fatal-errors/errors-slice'; export { closeCurrentFile, setCurrentFile, setCurrentFolder, setFileListing } from './markdown-files/files-slice'; export { setSaving } from './notifications/notifications-slice'; export { closeOverlay, confirmDelete, overrideActiveOverlay, setActiveOverlay } from './overlay/overlay-slice'; +export { setUpdateStatus } from './updates/update-slice'; diff --git a/src/renderer/redux/reducer.ts b/src/renderer/redux/reducer.ts index bfa0b993..62b565ce 100644 --- a/src/renderer/redux/reducer.ts +++ b/src/renderer/redux/reducer.ts @@ -4,6 +4,7 @@ import errorReducer from './fatal-errors/errors-slice'; import filesReducer from './markdown-files/files-slice'; import notificationsReducer from './notifications/notifications-slice'; import overlayReducer from './overlay/overlay-slice'; +import updateReducer from './updates/update-slice'; export const reducer = { about: aboutReducer, @@ -12,4 +13,5 @@ export const reducer = { files: filesReducer, notifications: notificationsReducer, overlay: overlayReducer, + updates: updateReducer, } as const; diff --git a/src/renderer/redux/updates/update-slice.ts b/src/renderer/redux/updates/update-slice.ts new file mode 100644 index 00000000..88987bb6 --- /dev/null +++ b/src/renderer/redux/updates/update-slice.ts @@ -0,0 +1,22 @@ +import { createAction, createSlice } from '@reduxjs/toolkit'; +import type { UpdateStatus } from '~shared/model'; + +export type UpdateSlice = { loading: false; status?: undefined } | { loading: true; status: UpdateStatus }; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const initialState = { loading: true } as UpdateSlice; + +export const setUpdateStatus = createAction('setUpdateDownloaded'); + +const slice = createSlice({ + name: 'about-details', + initialState, + reducers: {}, + extraReducers: (builder) => + builder.addCase(setUpdateStatus, (state, { payload: status }) => { + state.loading = true; + state.status = status; + }), +}); + +export default slice.reducer; diff --git a/src/shared/model/AboutDetails.ts b/src/shared/model/AboutDetails.ts index 9ee2beed..b874da92 100644 --- a/src/shared/model/AboutDetails.ts +++ b/src/shared/model/AboutDetails.ts @@ -3,9 +3,6 @@ export interface AboutDetails { version: string; isDevBuild: boolean; - updateExists: boolean; - updateVersion?: string; - osName: string; osVersion: string; } diff --git a/src/shared/model/UpdateStatus.ts b/src/shared/model/UpdateStatus.ts new file mode 100644 index 00000000..1bcaa179 --- /dev/null +++ b/src/shared/model/UpdateStatus.ts @@ -0,0 +1,7 @@ +export interface UpdateStatus { + canCheckForUpdates: boolean; + checkingForUpdate: boolean; + updateExists: boolean; + updateDownloaded: boolean; + updateVersion?: string; +} diff --git a/src/shared/model/index.ts b/src/shared/model/index.ts index 6ba5ec90..92983e90 100644 --- a/src/shared/model/index.ts +++ b/src/shared/model/index.ts @@ -2,3 +2,4 @@ export * from './AboutDetails'; export * from './AppConfiguration'; export * from './FolderConfiguration'; export * from './LinuxInstallOptions'; +export * from './UpdateStatus'; diff --git a/src/shared/renderer-api.d.ts b/src/shared/renderer-api.d.ts index 4dd6c096..ee576293 100644 --- a/src/shared/renderer-api.d.ts +++ b/src/shared/renderer-api.d.ts @@ -1,12 +1,20 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ -import { AboutDetails, AppConfiguration, FileDescription, FolderConfiguration, LinuxInstallOptions } from './model'; +import { + AboutDetails, + AppConfiguration, + FileDescription, + FolderConfiguration, + LinuxInstallOptions, + UpdateStatus, +} from './model'; import { OpenDialogReturnValue } from 'electron'; declare global { export interface EditorApi { readonly aboutDetails: Promise; readonly addFolder: (name: string, localPath: string) => Promise; + readonly checkForUpdates: () => void; readonly deleteFolder: (uuid: string) => Promise; readonly doLinuxInstallation: (options: LinuxInstallOptions) => Promise; readonly getAppConfiguration: () => Promise; @@ -20,10 +28,11 @@ declare global { readonly setLastFolder: (uuid: string) => Promise; readonly on: { - (event: 'files-updated', handler: (files: FolderConfiguration) => void): number; (event: 'configuration', handler: (config: AppConfiguration) => void): number; + (event: 'files-updated', handler: (files: FolderConfiguration) => void): number; + (event: 'update-status', handler: (status: UpdateStatus) => void): number; }; - readonly off: (event: 'files-updated' | 'configuration', handler: number) => void; + readonly off: (event: 'configuration' | 'files-updated' | 'update-status', handler: number) => void; } export interface EditorGlobalApi {