From 655465a59a1b55a144cae5d65ccb6355fb47ce64 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 29 May 2026 15:10:04 -0400 Subject: [PATCH] ESDP - List Parser - Notification fixes 0.0.5 --- .gitignore | 2 + electron-builder.yml | 5 + package.json | 2 +- scripts/installer.nsh | 18 +- src/main/decoder/decoder.ts | 124 ++++++++-- .../estimate-scrubber/estimate-scrubber.ts | 41 ++-- src/main/index.ts | 220 ++++++++++++++++-- src/main/util/notification.ts | 10 + src/renderer/src/components/Home/Home.tsx | 35 ++- 9 files changed, 384 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 96d0573..9e382b8 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ override.tf.json .terraformrc terraform.rc /.eslintcache +/docs +/.idea \ No newline at end of file diff --git a/electron-builder.yml b/electron-builder.yml index 499af9e..208e712 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,6 +19,10 @@ asarUnpack: win: executableName: EMSDP icon: resources/icon.png + protocols: + - name: EMS Data Pump + schemes: + - esdp azureSignOptions: endpoint: https://eus.codesigning.azure.net certificateProfileName: ImEXRPS @@ -38,6 +42,7 @@ mac: - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + - NSUserNotificationAlertStyle: alert - CFBundleURLTypes: - CFBundleTypeRole: Viewer # More specific role for protocol handling CFBundleURLName: com.imex.esdp diff --git a/package.json b/package.json index ae80176..cfd4e77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "esdp", "productName": "EMS Uploader", - "version": "0.0.4", + "version": "0.0.5", "description": "EMS Uploader", "main": "./out/main/index.js", "author": "ImEX Systems Inc.", diff --git a/scripts/installer.nsh b/scripts/installer.nsh index 0333f74..11168c7 100644 --- a/scripts/installer.nsh +++ b/scripts/installer.nsh @@ -1,13 +1,13 @@ !macro customInstall - ; Register imexmedia protocol - WriteRegStr HKCR "imexmedia" "" "URL:ImEX Shop Partner Protocol" - WriteRegStr HKCR "imexmedia" "URL Protocol" "" - WriteRegStr HKCR "imexmedia\\shell" "" "" - WriteRegStr HKCR "imexmedia\\shell\\open" "" "" - WriteRegStr HKCR "imexmedia\\shell\\open\\command" "" '"$INSTDIR\ShopPartner.exe" "%1"' + ; Register esdp protocol + WriteRegStr HKCR "esdp" "" "URL:EMS Data Pump Protocol" + WriteRegStr HKCR "esdp" "URL Protocol" "" + WriteRegStr HKCR "esdp\\shell" "" "" + WriteRegStr HKCR "esdp\\shell\\open" "" "" + WriteRegStr HKCR "esdp\\shell\\open\\command" "" '"$INSTDIR\EMSDP.exe" "%1"' !macroend !macro customUnInstall - ; Remove imexmedia protocol - DeleteRegKey HKCR "imexmedia" -!macroend \ No newline at end of file + ; Remove esdp protocol + DeleteRegKey HKCR "esdp" +!macroend diff --git a/src/main/decoder/decoder.ts b/src/main/decoder/decoder.ts index 47d0f87..8eb09c3 100644 --- a/src/main/decoder/decoder.ts +++ b/src/main/decoder/decoder.ts @@ -1,5 +1,6 @@ import { platform } from "@electron-toolkit/utils"; import { UUID } from "crypto"; +import { app } from "electron"; import log from "electron-log/main"; import fs from "fs"; import _ from "lodash"; @@ -40,6 +41,65 @@ import getMainWindow from "../../util/getMainWindow"; import newWindow from "../../util/newWindow"; import { createNotification, showNotification } from "../util/notification"; +const protocol = "esdp"; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function createProtocolUrl( + route: string, + params: Record, +): string { + const searchParams = new URLSearchParams(params); + return `${protocol}://${route}?${searchParams.toString()}`; +} + +function createScrubbedToastXml({ + title, + body, + scrubHistoryJobId, + scrubPdfURL, +}: { + title: string; + body: string; + scrubHistoryJobId?: string; + scrubPdfURL?: string; +}): string { + const viewInAppUrl = scrubHistoryJobId + ? createProtocolUrl("scrub-history", { jobId: scrubHistoryJobId }) + : undefined; + const openPdfUrl = scrubPdfURL + ? createProtocolUrl("open-pdf", { url: scrubPdfURL }) + : undefined; + + const actions = [ + viewInAppUrl + ? `` + : "", + openPdfUrl + ? `` + : "", + ].join(""); + + return ` + + + ${escapeXml(title)} + ${escapeXml(body)} + + + ${actions ? `${actions}` : ""} +`; +} + async function ImportJob(filepath: string): Promise { const parsedFilePath = path.parse(filepath); const extensionlessFilePath = path.join( @@ -140,6 +200,11 @@ async function ImportJob(filepath: string): Promise { //Scrub the estimate const scrubResult = await ScrubEstimate({ job: jobObject }); + if (!scrubResult) { + setAppProgressbar(-1); + return; + } + const scrubPdfURL = scrubResult?.pdfUrl; const scrubHistoryJobId = scrubResult?.jobId; @@ -154,15 +219,6 @@ async function ImportJob(filepath: string): Promise { }); setAppProgressbar(-1); - const uploadNotification = createNotification({ - title: "Job Scrubbed", - body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}.`, - actions: [ - { text: "View in App", type: "button" as const }, - ...(scrubPdfURL ? [{ text: "View PDF", type: "button" as const }] : []), - ], - }); - const openScrubHistoryItem = (): void => { const mainWindow = getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; @@ -170,7 +226,9 @@ async function ImportJob(filepath: string): Promise { mainWindow.restore(); } mainWindow.show(); + mainWindow.moveTop(); mainWindow.focus(); + app.focus({ steal: true }); if (scrubHistoryJobId) { mainWindow.webContents.send(ipcTypes.toRenderer.scrub.openHistoryItem, { jobId: scrubHistoryJobId, @@ -178,16 +236,48 @@ async function ImportJob(filepath: string): Promise { } }; + const notificationTitle = "Job Scrubbed"; + const notificationBody = `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}.`; + const notificationActionHandlers: Array<() => void> = []; + const notificationActions: Electron.NotificationAction[] = []; + + if (scrubHistoryJobId) { + notificationActions.push({ + text: "View in App", + type: "button" as const, + }); + notificationActionHandlers.push(openScrubHistoryItem); + } + + if (scrubPdfURL) { + notificationActions.push({ text: "View PDF", type: "button" as const }); + notificationActionHandlers.push(() => newWindow(scrubPdfURL)); + } + + const uploadNotification = createNotification( + platform.isWindows + ? { + title: notificationTitle, + body: notificationBody, + toastXml: createScrubbedToastXml({ + title: notificationTitle, + body: notificationBody, + scrubHistoryJobId, + scrubPdfURL, + }), + } + : { + title: notificationTitle, + body: notificationBody, + actions: notificationActions, + }, + ); + uploadNotification.on("click", openScrubHistoryItem); - uploadNotification.on("action", (e) => { - if (e.actionIndex === 0) { - openScrubHistoryItem(); - } else if (e.actionIndex === 1) { - if (scrubPdfURL) { - newWindow(scrubPdfURL); - } - } + uploadNotification.on("action", (event, actionIndex) => { + const selectedActionIndex = event.actionIndex ?? actionIndex; + notificationActionHandlers[selectedActionIndex]?.(); }); uploadNotification.show(); diff --git a/src/main/estimate-scrubber/estimate-scrubber.ts b/src/main/estimate-scrubber/estimate-scrubber.ts index c806a4b..2e77c18 100644 --- a/src/main/estimate-scrubber/estimate-scrubber.ts +++ b/src/main/estimate-scrubber/estimate-scrubber.ts @@ -92,15 +92,19 @@ async function ScrubEstimate({ const esApiKey = store.get("settings.esApiKey") as string; if (!esApiKey || esApiKey.trim() === "") { + const message = + "You must have a valid and active API key set in order to scrub estimates."; + showNotification({ title: "No API Key Set", - body: "You must have a valid and active API key set in order to scrub estimates.", + body: message, }); mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, { - message: - "You must have a valid and active API key set in order to scrub estimates.", + message, }); + + return undefined; } if (!job) { @@ -183,24 +187,21 @@ async function ScrubEstimate({ : undefined; const scrubErrorMessage = getErrorMessage(responseMessage); - if (status === 400) { - mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, { - message: - scrubErrorMessage || - "Error encountered sending estimate to Estimate Scrubber.", - }); - } else if (status === 401) { - showNotification({ - title: "Error scrubbing estimate", - body: - scrubErrorMessage || "Authentication with Estimate Scrubber failed.", - }); + const message = + scrubErrorMessage || + (status === 401 + ? "Authentication with Estimate Scrubber failed." + : "Error encountered sending estimate to Estimate Scrubber."); + + showNotification({ + title: "Error scrubbing estimate", + body: status ? `${message} (${status})` : message, + }); + + mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, { + message, + }); - mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, { - message: - scrubErrorMessage || "Authentication with Estimate Scrubber failed.", - }); - } return undefined; } } diff --git a/src/main/index.ts b/src/main/index.ts index f0d7e49..4b3b7f7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,9 +12,11 @@ import { } from "electron"; import log from "electron-log/main"; import { autoUpdater } from "electron-updater"; +import { execFile } from "node:child_process"; import path, { join } from "path"; import "source-map-support/register"; import imexAppIcon from "../../resources/icon.png?asset"; +import newWindow from "../util/newWindow"; import { default as ErrorTypeCheck, @@ -54,7 +56,9 @@ log.transports.console.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}"; log.transports.file.maxSize = 50 * 1024 * 1024; // 50 MB const isMac: boolean = process.platform === "darwin"; +const appId = "com.imex.esdp"; const protocol: string = "esdp"; +const protocolDescription = "EMS Data Pump Protocol"; let isAppQuitting = false; //Needed on Mac as an override to allow us to fully quit the app. let isKeepAliveLaunch = false; // Track if launched via keep-alive // Initialize the server @@ -68,7 +72,7 @@ if (!gotTheLock) { app.quit(); // Quit the app if another instance is already running } -function createWindow(): void { +function createWindow(): BrowserWindow { // Create the browser window. const { width, height, x, y } = store.get("app.windowBounds") as { width: number; @@ -459,6 +463,8 @@ function createWindow(): void { if (import.meta.env.DEV) { mainWindow.webContents.openDevTools(); } + + return mainWindow; } // This method will be called when Electron has finished @@ -471,7 +477,8 @@ app.whenReady().then(async () => { log.debug("App is ready, initializing shortcuts and protocol handlers."); if (platform.isWindows) { - app.setAppUserModelId("esdp"); + app.setAppUserModelId(appId); + await repairWindowsProtocolRegistration(); } app.on("browser-window-created", (_, window) => { @@ -483,14 +490,16 @@ app.whenReady().then(async () => { // remove so we can register each time as we run the app. app.removeAsDefaultProtocolClient(protocol); + const protocolLaunchArgs = getProtocolLaunchArgs(); + // If we are running a non-packaged version of the app && on windows - if (process.env.NODE_ENV === "development" && process.platform === "win32") { + if (platform.isWindows && protocolLaunchArgs) { // Set the path of electron.exe and your app. // These two additional parameters are only available on windows. isDefaultProtocolClient = app.setAsDefaultProtocolClient( protocol, process.execPath, - [path.resolve(process.argv[1])], + protocolLaunchArgs, ); } else { isDefaultProtocolClient = app.setAsDefaultProtocolClient(protocol); @@ -609,7 +618,13 @@ app.whenReady().then(async () => { }); } //The update itself will run when the bodyshop record is queried to know what release channel to use. - openMainWindow(); + const mainWindow = openMainWindow(); + const launchProtocolUrl = args.find((arg) => + arg.startsWith(`${protocol}://`), + ); + if (launchProtocolUrl) { + handleProtocolUrl(launchProtocolUrl, mainWindow); + } app.on("activate", function () { openMainWindow(); @@ -618,29 +633,14 @@ app.whenReady().then(async () => { app.on("open-url", (event: Electron.Event, url: string) => { event.preventDefault(); - if (url.startsWith(`${protocol}://keep-alive`)) { - log.info("Keep-alive protocol received."); - // Do nothing, whether app is running or not - return; - } else { - openInExplorer(url); - } + handleProtocolUrl(url); }); // Add this event handler for second instance app.on("second-instance", (_event: Electron.Event, argv: string[]) => { const url = argv.find((arg) => arg.startsWith(`${protocol}://`)); if (url) { - if (url.startsWith(`${protocol}://keep-alive`)) { - log.info( - "Keep-alive protocol received, app is already running. Nothing to do.", - ); - // Do nothing if already running - return; - } else { - log.info("Received Media URL: ", url); - openInExplorer(url); - } + handleProtocolUrl(url); } // No action taken if no URL is provided }); @@ -682,13 +682,183 @@ function preQuitMethods(): void { isAppQuitting = true; } -function openMainWindow(): void { +function runRegCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + "reg.exe", + args, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) { + reject( + new Error( + `reg.exe ${args.join(" ")} failed: ${stderr || stdout || error.message}`, + ), + ); + return; + } + + resolve(); + }, + ); + }); +} + +function getProtocolLaunchArgs(): string[] | null { + if (!is.dev && !process.defaultApp) { + return null; + } + + const launchArgs = process.argv + .slice(1) + .filter((arg) => !arg.startsWith(`${protocol}://`)); + + if (launchArgs.length === 0) { + return null; + } + + return launchArgs.map((arg) => + arg.startsWith("-") ? arg : path.resolve(arg), + ); +} + +function quoteCommandArg(arg: string): string { + return `"${arg.replaceAll('"', '\\"')}"`; +} + +function getProtocolCommand(): string { + const protocolLaunchArgs = getProtocolLaunchArgs(); + const commandParts = [process.execPath, ...(protocolLaunchArgs ?? []), "%1"]; + + return commandParts.map(quoteCommandArg).join(" "); +} + +async function repairWindowsProtocolRegistration(): Promise { + const protocolRoot = `HKCU\\Software\\Classes\\${protocol}`; + const protocolCommand = getProtocolCommand(); + + try { + await runRegCommand([ + "add", + protocolRoot, + "/ve", + "/d", + `URL:${protocolDescription}`, + "/f", + ]); + await runRegCommand([ + "add", + protocolRoot, + "/v", + "URL Protocol", + "/t", + "REG_SZ", + "/d", + "", + "/f", + ]); + await runRegCommand([ + "add", + `${protocolRoot}\\DefaultIcon`, + "/ve", + "/d", + `"${process.execPath}",0`, + "/f", + ]); + await runRegCommand([ + "add", + `${protocolRoot}\\shell\\open\\command`, + "/ve", + "/d", + protocolCommand, + "/f", + ]); + log.info("Windows protocol registry repaired.", { + protocol, + command: protocolCommand, + }); + } catch (error) { + log.warn( + "Failed to repair Windows protocol registry.", + errorTypeCheck(error), + ); + } +} + +function openMainWindow(): BrowserWindow { const mainWindow = getMainWindow(); if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } mainWindow.show(); - } else { - createWindow(); + mainWindow.moveTop(); + mainWindow.focus(); + app.focus({ steal: true }); + return mainWindow; } + + return createWindow(); +} + +function openScrubHistoryItem( + jobId: string, + targetWindow?: BrowserWindow, +): void { + const mainWindow = targetWindow ?? openMainWindow(); + const sendOpenHistoryItem = (): void => { + mainWindow.webContents.send(ipcTypes.toRenderer.scrub.openHistoryItem, { + jobId, + }); + }; + + if (mainWindow.webContents.isLoading()) { + mainWindow.webContents.once("did-finish-load", sendOpenHistoryItem); + } else { + sendOpenHistoryItem(); + } +} + +function handleProtocolUrl(url: string, targetWindow?: BrowserWindow): void { + if (url.startsWith(`${protocol}://keep-alive`)) { + log.info("Keep-alive protocol received."); + return; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch (error) { + log.warn("Invalid protocol URL received:", url, errorTypeCheck(error)); + return; + } + + if (parsedUrl.protocol !== `${protocol}:`) { + return; + } + + if (parsedUrl.hostname === "scrub-history") { + const jobId = + parsedUrl.searchParams.get("jobId") || + decodeURIComponent(parsedUrl.pathname.replace(/^\/+/, "")); + if (jobId) { + openScrubHistoryItem(jobId, targetWindow); + } + return; + } + + if (parsedUrl.hostname === "open-pdf") { + const pdfUrl = parsedUrl.searchParams.get("url"); + if (pdfUrl) { + newWindow(pdfUrl).catch((error) => { + log.error("Failed to open notification PDF:", errorTypeCheck(error)); + }); + } + return; + } + + log.info("Received legacy protocol URL: ", url); + openInExplorer(url); } function openInExplorer(url: string): void { diff --git a/src/main/util/notification.ts b/src/main/util/notification.ts index d5dbda8..208ff2b 100644 --- a/src/main/util/notification.ts +++ b/src/main/util/notification.ts @@ -1,13 +1,23 @@ import { Notification, type NotificationConstructorOptions } from "electron"; import log from "electron-log/main"; +const retainedNotifications = new Set(); +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; + function createNotification( options: NotificationConstructorOptions, context = options.title, ): Notification { const notification = new Notification(options); + retainedNotifications.add(notification); + + const retentionTimer = setTimeout(() => { + retainedNotifications.delete(notification); + }, DEFAULT_RETENTION_MS); + retentionTimer.unref?.(); notification.on("failed", (_event, error) => { + retainedNotifications.delete(notification); log.warn(`Notification failed${context ? ` (${context})` : ""}:`, error); }); diff --git a/src/renderer/src/components/Home/Home.tsx b/src/renderer/src/components/Home/Home.tsx index eed0805..ed29b29 100644 --- a/src/renderer/src/components/Home/Home.tsx +++ b/src/renderer/src/components/Home/Home.tsx @@ -29,7 +29,15 @@ import { Tooltip, Typography, } from "antd"; -import { FC, UIEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { + FC, + UIEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router"; import { selectWatcherStatus } from "@renderer/redux/app.slice"; @@ -104,6 +112,7 @@ const Home: FC = () => { null, ); const [loadingMore, setLoadingMore] = useState(false); + const historyItemRefs = useRef>(new Map()); const routeSelectedJobId = searchParams.get("jobId")?.trim() || null; const selectedJobId = routeSelectedJobId ?? manualSelectedJobId; @@ -245,6 +254,27 @@ const Home: FC = () => { await refresh(loadedPage + 1, true); }, [hasMoreHistory, loadedPage, loading, loadingMore, refresh]); + const setHistoryItemRef = useCallback( + (jobId: string, element: HTMLDivElement | null) => { + if (element) { + historyItemRefs.current.set(jobId, element); + } else { + historyItemRefs.current.delete(jobId); + } + }, + [], + ); + + useEffect(() => { + if (!selectedJobId || loading) return; + + const selectedElement = historyItemRefs.current.get(selectedJobId); + if (!selectedElement) return; + + selectedElement.scrollIntoView({ block: "nearest" }); + selectedElement.focus({ preventScroll: true }); + }, [history, loading, selectedJobId]); + const handleHistoryScroll = useCallback( (event: UIEvent) => { const target = event.currentTarget; @@ -576,6 +606,9 @@ const Home: FC = () => { return (
+ setHistoryItemRef(record.id, element) + } role="button" tabIndex={0} onClick={() => selectJob(record.id)}