Add better update handler.

This commit is contained in:
Patrick Fic
2025-03-25 14:45:13 -07:00
parent 6b1876b0f4
commit 70e14fb5cc
15 changed files with 389 additions and 45 deletions

View File

@@ -1,3 +1,3 @@
provider: generic provider: s3
url: https://example.com/auto-updates bucket: imex-partner
updaterCacheDirName: bodyshop-desktop-updater region: ca-central-1

View File

@@ -13,11 +13,11 @@ asarUnpack:
- resources/** - resources/**
win: win:
executableName: bodyshop-desktop executableName: bodyshop-desktop
azureSignOptions: azureSignOptions:
endpoint: https://eus.codesigning.azure.net endpoint: https://eus.codesigning.azure.net
certificateProfileName: ImEXRPS certificateProfileName: ImEXRPS
codeSigningAccountName: ImEX codeSigningAccountName: ImEX
nsis: nsis:
artifactName: ${name}-${version}-setup.${ext} artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName} shortcutName: ${productName}
@@ -32,7 +32,7 @@ mac:
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
target: target:
- target: dmg - target: default
arch: arch:
- universal - universal
dmg: dmg:

View File

@@ -45,7 +45,7 @@ const DecodeAD2 = async (
"CLMT_PH2", "CLMT_PH2",
"CLMT_PH2X", "CLMT_PH2X",
"CLMT_FAX", "CLMT_FAX",
"CLMT_FAXX", //"CLMT_FAXX",
"CLMT_EA", "CLMT_EA",
//"EST_CO_ID", //"EST_CO_ID",
"EST_CO_NM", "EST_CO_NM",

View File

@@ -1,12 +1,14 @@
import { electronApp, is, optimizer } from "@electron-toolkit/utils"; import { electronApp, is, optimizer } from "@electron-toolkit/utils";
import { app, BrowserWindow, Menu, shell } from "electron"; import { app, BrowserWindow, Menu, shell } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
import { autoUpdater } from "electron-updater";
import path, { join } from "path"; import path, { join } from "path";
import icon from "../../resources/icon.png?asset"; import icon from "../../resources/icon.png?asset";
import ErrorTypeCheck from "../util/errorTypeCheck"; import ErrorTypeCheck from "../util/errorTypeCheck";
import ipcTypes from "../util/ipcTypes.json";
import client from "./graphql/graphql-client"; import client from "./graphql/graphql-client";
import store from "./store/store"; import store from "./store/store";
import { autoUpdater } from "electron-updater"; import { createPublicKey } from "crypto";
log.initialize(); log.initialize();
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
@@ -131,6 +133,12 @@ function createWindow(): void {
{ {
label: "Development", label: "Development",
submenu: [ submenu: [
{
label: "Check for updates",
click: (): void => {
autoUpdater.checkForUpdates();
},
},
{ {
label: "Open Log Folder", label: "Open Log Folder",
click: (): void => { click: (): void => {
@@ -251,6 +259,42 @@ app.whenReady().then(async () => {
//Check for app updates. //Check for app updates.
autoUpdater.logger = log; 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(); autoUpdater.checkForUpdatesAndNotify();
createWindow(); createWindow();

View File

@@ -10,6 +10,7 @@ import {
SettingsWatchedFilePathsRemove, SettingsWatchedFilePathsRemove,
} from "./ipcMainHandler.settings"; } from "./ipcMainHandler.settings";
import { ipcMainHandleAuthStateChanged } from "./ipcMainHandler.user"; import { ipcMainHandleAuthStateChanged } from "./ipcMainHandler.user";
import { autoUpdater } from "electron-updater";
// Log all IPC messages and their payloads // Log all IPC messages and their payloads
const logIpcMessages = (): void => { const logIpcMessages = (): void => {
@@ -87,4 +88,19 @@ ipcMain.on(ipcTypes.toMain.watcher.stop, () => {
StopWatcher(); 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(); logIpcMessages();

View File

@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { HashRouter, Route, Routes } from "react-router"; 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 ErrorBoundaryFallback from "./components/ErrorBoundaryFallback/ErrorBoundaryFallback";
import Home from "./components/Home/Home"; import Home from "./components/Home/Home";
import NavigationHeader from "./components/NavigationHeader/Navigationheader"; import NavigationHeader from "./components/NavigationHeader/Navigationheader";
@@ -13,6 +13,8 @@ import Settings from "./components/Settings/Settings";
import SignInForm from "./components/SignInForm/SignInForm"; import SignInForm from "./components/SignInForm/SignInForm";
import reduxStore from "./redux/redux-store"; import reduxStore from "./redux/redux-store";
import { auth } from "./util/firebase"; import { auth } from "./util/firebase";
import { NotificationProvider } from "./util/notificationContext";
import UpdateAvailable from "./components/UpdateAvailable/UpdateAvailable";
const App: React.FC = () => { const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
@@ -39,19 +41,22 @@ const App: React.FC = () => {
<Provider store={reduxStore}> <Provider store={reduxStore}>
<HashRouter> <HashRouter>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}> <ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<Layout> <NotificationProvider>
{!user ? ( <Layout>
<SignInForm /> {!user ? (
) : ( <SignInForm />
<> ) : (
<NavigationHeader /> <>
<Routes> <NavigationHeader />
<Route path="/" element={<Home />} /> <UpdateAvailable />
<Route path="settings" element={<Settings />} /> <Routes>
</Routes> <Route path="/" element={<Home />} />
</> <Route path="settings" element={<Settings />} />
)} </Routes>
</Layout> </>
)}
</Layout>
</NotificationProvider>
</ErrorBoundary> </ErrorBoundary>
</HashRouter> </HashRouter>
</Provider> </Provider>

View File

@@ -14,6 +14,15 @@ const Home: React.FC = () => {
> >
Test Decode Estimate Test Decode Estimate
</Button> </Button>
<Button
onClick={(): void => {
window.electron.ipcRenderer.send(
ipcTypes.toMain.updates.checkForUpdates,
);
}}
>
Check for Update
</Button>
</div> </div>
); );
}; };

View File

@@ -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 (
<Affix offsetTop={40} style={{ position: "absolute", right: 20 }}>
<Card title={t("updates.available")} style={{ width: "33vw" }}>
{updateProgress === 0 && (
<Button onClick={handleDownload}>{t("updates.download")}</Button>
)}
<Progress
percent={updateProgress}
percentPosition={{ align: "center", type: "outer" }}
/>
{formatSpeed(updateSpeed)}
{isUpdateComplete && (
<Button onClick={handleApply}>{t("updates.apply")}</Button>
)}
</Card>
</Affix>
);
};
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]}`;
};

View File

@@ -1,12 +1,21 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import log from "electron-log/renderer"; import log from "electron-log/renderer";
import type { RootState } from "./redux-store"; import type { RootState } from "./redux-store";
import { update } from "lodash";
import { notification } from "antd";
interface AppState { interface AppState {
value: number; value: number;
watcher: { watcher: {
started: boolean; started: boolean;
error: string | null; error: string | null;
}; };
updates: {
available: boolean;
checking: boolean;
progress: number;
speed: number;
completed: boolean;
};
} }
// Define the initial state using that type // Define the initial state using that type
@@ -16,6 +25,13 @@ const initialState: AppState = {
started: false, started: false,
error: null, error: null,
}, },
updates: {
available: false,
checking: false,
progress: 0,
speed: 0,
completed: false,
},
}; };
export const appSlice = createSlice({ export const appSlice = createSlice({
@@ -34,11 +50,36 @@ export const appSlice = createSlice({
state.watcher.started = false; state.watcher.started = false;
log.error("[Redux] AppSlice: Watcher Error", action.payload); 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 } = export const {
appSlice.actions; watcherError,
watcherStarted,
watcherStopped,
updateAvailable,
updateChecking,
updateDownloaded,
updateProgress,
} = appSlice.actions;
// Other code such as selectors can use the imported `RootState` type // Other code such as selectors can use the imported `RootState` type
export const selectWatcherStatus = (state: RootState): boolean => export const selectWatcherStatus = (state: RootState): boolean =>
@@ -47,6 +88,18 @@ export const selectWatcherStatus = (state: RootState): boolean =>
export const selectWatcherError = (state: RootState): string | null => export const selectWatcherError = (state: RootState): string | null =>
state.app.watcher.error; 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 //Async Functions - Thunks
// Define a thunk that dispatches those action creators // Define a thunk that dispatches those action creators
// const fetchUsers = () => async (dispatch) => { // const fetchUsers = () => async (dispatch) => {
@@ -55,4 +108,12 @@ export const selectWatcherError = (state: RootState): string | null =>
// // dispatch(incrementByAmount(100)); // // 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; export default appSlice.reducer;

View File

@@ -1,5 +1,9 @@
//Set up all of the IPC handlers. //Set up all of the IPC handlers.
import { import {
updateAvailable,
updateChecking,
updateDownloaded,
updateProgress,
watcherError, watcherError,
watcherStarted, watcherStarted,
watcherStopped, watcherStopped,
@@ -52,3 +56,31 @@ ipcRenderer.on(
dispatch(watcherError(error)); 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());
},
);

View File

@@ -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 (
<NotificationContext.Provider value={api}>
{/* contextHolder must be rendered in the DOM so notifications can appear */}
{contextHolder}
{children}
</NotificationContext.Provider>
);
};

View File

@@ -5,6 +5,11 @@
"debug": { "debug": {
"decodeEstimate": "toMain_debug_decodeEstimate" "decodeEstimate": "toMain_debug_decodeEstimate"
}, },
"updates": {
"checkForUpdates": "toMain_updates_checkForUpdates",
"download": "toMain_updates_download",
"apply": "toMain_updates_apply"
},
"watcher": { "watcher": {
"start": "toMain_watcher_start", "start": "toMain_watcher_start",
"stop": "toMain_watcher_stop" "stop": "toMain_watcher_stop"
@@ -27,6 +32,14 @@
"stopped": "toRenderer_watcher_stopped", "stopped": "toRenderer_watcher_stopped",
"error": "toRenderer_watcher_error" "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": { "user": {
"getToken": "toRenderer_user_getToken" "getToken": "toRenderer_user_getToken"
} }

View File

@@ -1,5 +1,5 @@
{ {
"toolbar": { "toolbar": {
"help": "Help" "help": "Help"
} }
} }

View File

@@ -1,20 +1,26 @@
{ {
"translation": { "translation": {
"navigation": { "navigation": {
"home": "Home", "home": "Home",
"settings": "Settings" "settings": "Settings"
}, },
"settings": { "settings": {
"actions": { "actions": {
"addpath": "Add path", "addpath": "Add path",
"startwatcher": "Start Watcher", "startwatcher": "Start Watcher",
"stopwatcher": "Stop Watcher\n" "stopwatcher": "Stop Watcher\n"
}, },
"labels": { "labels": {
"started": "Started", "started": "Started",
"stopped": "Stopped", "stopped": "Stopped",
"watcherstatus": "Watcher Status" "watcherstatus": "Watcher Status"
} }
} },
} "updates": {
"apply": "Apply Update",
"available": "An update is available.",
"download": "Download Update",
"downloading": "An update is downloading."
}
}
} }

View File

@@ -169,6 +169,63 @@
</folder_node> </folder_node>
</children> </children>
</folder_node> </folder_node>
<folder_node>
<name>updates</name>
<children>
<concept_node>
<name>apply</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>available</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>download</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>downloading</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children> </children>
</folder_node> </folder_node>
</children> </children>