From 70e14fb5cc5d39e1dfc36ba59a5ab2170a7a0be9 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Tue, 25 Mar 2025 14:45:13 -0700 Subject: [PATCH] Add better update handler. --- dev-app-update.yml | 6 +- electron-builder.yml | 6 +- src/main/decoder/decode-ad2.ts | 2 +- src/main/index.ts | 46 ++++++++++++- src/main/ipc/ipcMainConfig.ts | 16 +++++ src/renderer/src/App.tsx | 33 ++++++---- src/renderer/src/components/Home/Home.tsx | 9 +++ .../UpdateAvailable/UpdateAvailable.tsx | 64 ++++++++++++++++++ src/renderer/src/redux/app.slice.ts | 65 ++++++++++++++++++- src/renderer/src/util/ipcRendererHandler.ts | 32 +++++++++ src/renderer/src/util/notificationContext.tsx | 37 +++++++++++ src/util/ipcTypes.json | 13 ++++ src/util/translations/en-US/main.json | 6 +- src/util/translations/en-US/renderer.json | 42 +++++++----- translations.babel | 57 ++++++++++++++++ 15 files changed, 389 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx create mode 100644 src/renderer/src/util/notificationContext.tsx diff --git a/dev-app-update.yml b/dev-app-update.yml index 249e2b2..8d30efd 100644 --- a/dev-app-update.yml +++ b/dev-app-update.yml @@ -1,3 +1,3 @@ -provider: generic -url: https://example.com/auto-updates -updaterCacheDirName: bodyshop-desktop-updater +provider: s3 +bucket: imex-partner +region: ca-central-1 diff --git a/electron-builder.yml b/electron-builder.yml index 9ea9467..f09456b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,11 +13,11 @@ asarUnpack: - resources/** win: executableName: bodyshop-desktop - azureSignOptions: + azureSignOptions: endpoint: https://eus.codesigning.azure.net certificateProfileName: ImEXRPS codeSigningAccountName: ImEX - + nsis: artifactName: ${name}-${version}-setup.${ext} shortcutName: ${productName} @@ -32,7 +32,7 @@ mac: - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. target: - - target: dmg + - target: default arch: - universal dmg: diff --git a/src/main/decoder/decode-ad2.ts b/src/main/decoder/decode-ad2.ts index ab80873..2d476b1 100644 --- a/src/main/decoder/decode-ad2.ts +++ b/src/main/decoder/decode-ad2.ts @@ -45,7 +45,7 @@ const DecodeAD2 = async ( "CLMT_PH2", "CLMT_PH2X", "CLMT_FAX", - "CLMT_FAXX", + //"CLMT_FAXX", "CLMT_EA", //"EST_CO_ID", "EST_CO_NM", diff --git a/src/main/index.ts b/src/main/index.ts index bfa73f1..260b249 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,12 +1,14 @@ import { electronApp, is, optimizer } from "@electron-toolkit/utils"; import { app, BrowserWindow, Menu, shell } from "electron"; import log from "electron-log/main"; +import { autoUpdater } from "electron-updater"; import path, { join } from "path"; import icon from "../../resources/icon.png?asset"; import ErrorTypeCheck from "../util/errorTypeCheck"; +import ipcTypes from "../util/ipcTypes.json"; import client from "./graphql/graphql-client"; import store from "./store/store"; -import { autoUpdater } from "electron-updater"; +import { createPublicKey } from "crypto"; log.initialize(); const isMac = process.platform === "darwin"; @@ -131,6 +133,12 @@ function createWindow(): void { { label: "Development", submenu: [ + { + label: "Check for updates", + click: (): void => { + autoUpdater.checkForUpdates(); + }, + }, { label: "Open Log Folder", click: (): void => { @@ -251,6 +259,42 @@ app.whenReady().then(async () => { //Check for app updates. autoUpdater.logger = log; + if (import.meta.env.DEV) { + // Useful for some dev/debugging tasks, but download can + // not be validated becuase dev app is not signed + autoUpdater.updateConfigPath = path.join( + __dirname, + "../../dev-app-update.yml", + ); + autoUpdater.forceDevUpdateConfig = true; + autoUpdater.autoDownload = false; + } + autoUpdater.on("checking-for-update", () => { + log.info("Checking for update..."); + const mainWindow = BrowserWindow.getAllWindows()[0]; + mainWindow?.webContents.send(ipcTypes.toRenderer.updates.checking); + }); + autoUpdater.on("update-available", (info) => { + log.info("Update available.", info); + const mainWindow = BrowserWindow.getAllWindows()[0]; + mainWindow?.webContents.send(ipcTypes.toRenderer.updates.available, info); + }); + autoUpdater.on("download-progress", (progress) => { + log.info(`Download speed: ${progress.bytesPerSecond}`); + log.info(`Downloaded ${progress.percent}%`); + log.info(`Total downloaded ${progress.transferred}/${progress.total}`); + const mainWindow = BrowserWindow.getAllWindows()[0]; + mainWindow?.webContents.send( + ipcTypes.toRenderer.updates.downloading, + progress, + ); + }); + autoUpdater.on("update-downloaded", (info) => { + log.info("Update downloaded", info); + const mainWindow = BrowserWindow.getAllWindows()[0]; + mainWindow?.webContents.send(ipcTypes.toRenderer.updates.downloaded, info); + }); + //autoUpdater.checkForUpdates(); autoUpdater.checkForUpdatesAndNotify(); createWindow(); diff --git a/src/main/ipc/ipcMainConfig.ts b/src/main/ipc/ipcMainConfig.ts index 51c9053..9d690af 100644 --- a/src/main/ipc/ipcMainConfig.ts +++ b/src/main/ipc/ipcMainConfig.ts @@ -10,6 +10,7 @@ import { SettingsWatchedFilePathsRemove, } from "./ipcMainHandler.settings"; import { ipcMainHandleAuthStateChanged } from "./ipcMainHandler.user"; +import { autoUpdater } from "electron-updater"; // Log all IPC messages and their payloads const logIpcMessages = (): void => { @@ -87,4 +88,19 @@ ipcMain.on(ipcTypes.toMain.watcher.stop, () => { StopWatcher(); }); +ipcMain.on(ipcTypes.toMain.updates.download, () => { + log.info("Download update requested from renderer."); + autoUpdater.downloadUpdate(); +}); + +ipcMain.on(ipcTypes.toMain.updates.checkForUpdates, () => { + log.info("Checking for updates from renderer."); + autoUpdater.checkForUpdates(); +}); + +ipcMain.on(ipcTypes.toMain.updates.apply, () => { + log.info("Applying update from renderer."); + autoUpdater.quitAndInstall(); +}); + logIpcMessages(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8df06c3..f42765e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Provider } from "react-redux"; import { HashRouter, Route, Routes } from "react-router"; -import ipcTypes from "../../util/ipcTypes"; +import ipcTypes from "../../util/ipcTypes.json"; import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback/ErrorBoundaryFallback"; import Home from "./components/Home/Home"; import NavigationHeader from "./components/NavigationHeader/Navigationheader"; @@ -13,6 +13,8 @@ import Settings from "./components/Settings/Settings"; import SignInForm from "./components/SignInForm/SignInForm"; import reduxStore from "./redux/redux-store"; import { auth } from "./util/firebase"; +import { NotificationProvider } from "./util/notificationContext"; +import UpdateAvailable from "./components/UpdateAvailable/UpdateAvailable"; const App: React.FC = () => { const [user, setUser] = useState(null); @@ -39,19 +41,22 @@ const App: React.FC = () => { - - {!user ? ( - - ) : ( - <> - - - } /> - } /> - - - )} - + + + {!user ? ( + + ) : ( + <> + + + + } /> + } /> + + + )} + + diff --git a/src/renderer/src/components/Home/Home.tsx b/src/renderer/src/components/Home/Home.tsx index 8b71e5a..7575fba 100644 --- a/src/renderer/src/components/Home/Home.tsx +++ b/src/renderer/src/components/Home/Home.tsx @@ -14,6 +14,15 @@ const Home: React.FC = () => { > Test Decode Estimate + ); }; diff --git a/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx b/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx new file mode 100644 index 0000000..9a467e6 --- /dev/null +++ b/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx @@ -0,0 +1,64 @@ +import { + selectAppUpdateCompleted, + selectAppUpdateProgress, + selectAppUpdateSpeed, + selectUpdateAvailable, +} from "@renderer/redux/app.slice"; +import { useAppSelector } from "@renderer/redux/reduxHooks"; +import { Affix, Button, Card, Progress } from "antd"; +import { useTranslation } from "react-i18next"; +import ipcTypes from "../../../../util/ipcTypes.json"; + +const UpdateAvailable: React.FC = () => { + const { t } = useTranslation(); + const isUpdateAvailable = useAppSelector(selectUpdateAvailable); + const updateSpeed = useAppSelector(selectAppUpdateSpeed); + const updateProgress = useAppSelector(selectAppUpdateProgress); + const isUpdateComplete = useAppSelector(selectAppUpdateCompleted); + + const handleDownload = (): void => { + window.electron.ipcRenderer.send(ipcTypes.toMain.updates.download); + }; + + const handleApply = (): void => { + window.electron.ipcRenderer.send(ipcTypes.toMain.updates.apply); + }; + + if (!isUpdateAvailable) { + return null; + } + return ( + + + {updateProgress === 0 && ( + + )} + + {formatSpeed(updateSpeed)} + {isUpdateComplete && ( + + )} + + + ); +}; + +export default UpdateAvailable; + +/** + * Formats bytes into a human-readable string with appropriate units + * @param bytes Number of bytes + * @returns Formatted string with appropriate unit (B/KB/MB/GB) + */ +const formatSpeed = (bytes: number): string => { + if (bytes === 0) return "0 B/s"; + + const units = ["B/s", "KB/s", "MB/s", "GB/s"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + // Limit to available units and format with 2 decimal places (rounded) + return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i] || units[units.length - 1]}`; +}; diff --git a/src/renderer/src/redux/app.slice.ts b/src/renderer/src/redux/app.slice.ts index 5bfd20e..1cafcf5 100644 --- a/src/renderer/src/redux/app.slice.ts +++ b/src/renderer/src/redux/app.slice.ts @@ -1,12 +1,21 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import log from "electron-log/renderer"; import type { RootState } from "./redux-store"; +import { update } from "lodash"; +import { notification } from "antd"; interface AppState { value: number; watcher: { started: boolean; error: string | null; }; + updates: { + available: boolean; + checking: boolean; + progress: number; + speed: number; + completed: boolean; + }; } // Define the initial state using that type @@ -16,6 +25,13 @@ const initialState: AppState = { started: false, error: null, }, + updates: { + available: false, + checking: false, + progress: 0, + speed: 0, + completed: false, + }, }; export const appSlice = createSlice({ @@ -34,11 +50,36 @@ export const appSlice = createSlice({ state.watcher.started = false; log.error("[Redux] AppSlice: Watcher Error", action.payload); }, + + updateChecking: (state) => { + state.updates.checking = true; + }, + updateAvailable: (state) => { + state.updates.available = true; + state.updates.checking = false; + }, + updateProgress: (state, action) => { + state.updates.available = true; + state.updates.progress = action?.progress; + state.updates.speed = action?.speed; + }, + updateDownloaded: (state) => { + state.updates.completed = true; + state.updates.progress = 100; + state.updates.speed = 0; + }, }, }); -export const { watcherError, watcherStarted, watcherStopped } = - appSlice.actions; +export const { + watcherError, + watcherStarted, + watcherStopped, + updateAvailable, + updateChecking, + updateDownloaded, + updateProgress, +} = appSlice.actions; // Other code such as selectors can use the imported `RootState` type export const selectWatcherStatus = (state: RootState): boolean => @@ -47,6 +88,18 @@ export const selectWatcherStatus = (state: RootState): boolean => export const selectWatcherError = (state: RootState): string | null => state.app.watcher.error; +export const selectUpdateAvailable = (state: RootState): boolean => + state.app.updates.available; + +export const selectAppUpdateProgress = (state: RootState): number => + state.app.updates.progress; + +export const selectAppUpdateSpeed = (state: RootState): number => + state.app.updates.speed; + +export const selectAppUpdateCompleted = (state: RootState): boolean => + state.app.updates.completed; + //Async Functions - Thunks // Define a thunk that dispatches those action creators // const fetchUsers = () => async (dispatch) => { @@ -55,4 +108,12 @@ export const selectWatcherError = (state: RootState): string | null => // // dispatch(incrementByAmount(100)); // }; +const updateAvailableThunk = () => async (dispatch) => { + notification.info({ + message: "Update Available", + key: "app-update", + description: "An update is available for download.", + }); +}; + export default appSlice.reducer; diff --git a/src/renderer/src/util/ipcRendererHandler.ts b/src/renderer/src/util/ipcRendererHandler.ts index 6264997..5279ec1 100644 --- a/src/renderer/src/util/ipcRendererHandler.ts +++ b/src/renderer/src/util/ipcRendererHandler.ts @@ -1,5 +1,9 @@ //Set up all of the IPC handlers. import { + updateAvailable, + updateChecking, + updateDownloaded, + updateProgress, watcherError, watcherStarted, watcherStopped, @@ -52,3 +56,31 @@ ipcRenderer.on( dispatch(watcherError(error)); }, ); + +//Update Handlers +ipcRenderer.on( + ipcTypes.toRenderer.updates.checking, + (event: Electron.IpcRendererEvent) => { + console.log("Checking for updates..."); + dispatch(updateChecking()); + }, +); + +ipcRenderer.on( + ipcTypes.toRenderer.updates.available, + (event: Electron.IpcRendererEvent, arg) => { + dispatch(updateAvailable()); + }, +); +ipcRenderer.on( + ipcTypes.toRenderer.updates.downloading, + (event: Electron.IpcRendererEvent, arg) => { + dispatch(updateProgress({ progress: arg.progress, speed: arg.speed })); + }, +); +ipcRenderer.on( + ipcTypes.toRenderer.updates.downloaded, + (event: Electron.IpcRendererEvent, arg) => { + dispatch(updateDownloaded()); + }, +); diff --git a/src/renderer/src/util/notificationContext.tsx b/src/renderer/src/util/notificationContext.tsx new file mode 100644 index 0000000..65665ba --- /dev/null +++ b/src/renderer/src/util/notificationContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext } from "react"; +import { notification } from "antd"; + +/** + * Create our NotificationContext to store the `api` object + * returned by notification.useNotification(). + */ +const NotificationContext = createContext(null); + +/** + * A custom hook to make usage easier in child components. + */ +export const useNotification = () => { + return useContext(NotificationContext); +}; + +/** + * The Provider itself: + * - Call notification.useNotification() to get [api, contextHolder]. + * - Render contextHolder somewhere high-level in your app (so the notifications mount properly). + * - Provide `api` via the NotificationContext. + */ +export const NotificationProvider = ({ children }) => { + const [api, contextHolder] = notification.useNotification({ + placement: "bottomRight", + bottom: 70, + showProgress: true, + }); + + return ( + + {/* contextHolder must be rendered in the DOM so notifications can appear */} + {contextHolder} + {children} + + ); +}; diff --git a/src/util/ipcTypes.json b/src/util/ipcTypes.json index 5137cb7..907ffa5 100644 --- a/src/util/ipcTypes.json +++ b/src/util/ipcTypes.json @@ -5,6 +5,11 @@ "debug": { "decodeEstimate": "toMain_debug_decodeEstimate" }, + "updates": { + "checkForUpdates": "toMain_updates_checkForUpdates", + "download": "toMain_updates_download", + "apply": "toMain_updates_apply" + }, "watcher": { "start": "toMain_watcher_start", "stop": "toMain_watcher_stop" @@ -27,6 +32,14 @@ "stopped": "toRenderer_watcher_stopped", "error": "toRenderer_watcher_error" }, + "updates": { + "checking": "toRenderer_updates_checking", + "available": "toRenderer_updates_available", + "notAvailable": "toRenderer_updates_notAvailable", + "error": "toRenderer_updates_error", + "downloading": "toRenderer_updates_downloading", + "downloaded": "toRenderer_updates_downloaded" + }, "user": { "getToken": "toRenderer_user_getToken" } diff --git a/src/util/translations/en-US/main.json b/src/util/translations/en-US/main.json index 6dd0159..8dba742 100644 --- a/src/util/translations/en-US/main.json +++ b/src/util/translations/en-US/main.json @@ -1,5 +1,5 @@ { - "toolbar": { - "help": "Help" - } + "toolbar": { + "help": "Help" + } } diff --git a/src/util/translations/en-US/renderer.json b/src/util/translations/en-US/renderer.json index 0f62d18..60e9f11 100644 --- a/src/util/translations/en-US/renderer.json +++ b/src/util/translations/en-US/renderer.json @@ -1,20 +1,26 @@ { - "translation": { - "navigation": { - "home": "Home", - "settings": "Settings" - }, - "settings": { - "actions": { - "addpath": "Add path", - "startwatcher": "Start Watcher", - "stopwatcher": "Stop Watcher\n" - }, - "labels": { - "started": "Started", - "stopped": "Stopped", - "watcherstatus": "Watcher Status" - } - } - } + "translation": { + "navigation": { + "home": "Home", + "settings": "Settings" + }, + "settings": { + "actions": { + "addpath": "Add path", + "startwatcher": "Start Watcher", + "stopwatcher": "Stop Watcher\n" + }, + "labels": { + "started": "Started", + "stopped": "Stopped", + "watcherstatus": "Watcher Status" + } + }, + "updates": { + "apply": "Apply Update", + "available": "An update is available.", + "download": "Download Update", + "downloading": "An update is downloading." + } + } } diff --git a/translations.babel b/translations.babel index e49a64c..836ba5b 100644 --- a/translations.babel +++ b/translations.babel @@ -169,6 +169,63 @@ + + updates + + + apply + false + + + + + + en-US + false + + + + + available + false + + + + + + en-US + false + + + + + download + false + + + + + + en-US + false + + + + + downloading + false + + + + + + en-US + false + + + + +