ESDP - List Parser - Notification fixes 0.0.5

This commit is contained in:
Dave
2026-05-29 15:10:04 -04:00
parent ead0d7d380
commit 655465a59a
9 changed files with 384 additions and 73 deletions

2
.gitignore vendored
View File

@@ -52,3 +52,5 @@ override.tf.json
.terraformrc
terraform.rc
/.eslintcache
/docs
/.idea

View File

@@ -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

View File

@@ -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.",

View File

@@ -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
; Remove esdp protocol
DeleteRegKey HKCR "esdp"
!macroend

View File

@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function createProtocolUrl(
route: string,
params: Record<string, string>,
): 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
? `<action content="View in App" activationType="protocol" arguments="${escapeXml(viewInAppUrl)}" />`
: "",
openPdfUrl
? `<action content="View PDF" activationType="protocol" arguments="${escapeXml(openPdfUrl)}" />`
: "",
].join("");
return `<toast activationType="protocol" launch="${escapeXml(
viewInAppUrl ?? openPdfUrl ?? `${protocol}://`,
)}">
<visual>
<binding template="ToastGeneric">
<text>${escapeXml(title)}</text>
<text>${escapeXml(body)}</text>
</binding>
</visual>
${actions ? `<actions>${actions}</actions>` : ""}
</toast>`;
}
async function ImportJob(filepath: string): Promise<void> {
const parsedFilePath = path.parse(filepath);
const extensionlessFilePath = path.join(
@@ -140,6 +200,11 @@ async function ImportJob(filepath: string): Promise<void> {
//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<void> {
});
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<void> {
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<void> {
}
};
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();

View File

@@ -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;
}
}

View File

@@ -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<void> {
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<void> {
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 {

View File

@@ -1,13 +1,23 @@
import { Notification, type NotificationConstructorOptions } from "electron";
import log from "electron-log/main";
const retainedNotifications = new Set<Notification>();
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);
});

View File

@@ -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<boolean>(false);
const historyItemRefs = useRef<Map<string, HTMLDivElement>>(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<HTMLDivElement>) => {
const target = event.currentTarget;
@@ -576,6 +606,9 @@ const Home: FC = () => {
return (
<List.Item style={{ padding: "0 0 8px" }}>
<div
ref={(element) =>
setHistoryItemRef(record.id, element)
}
role="button"
tabIndex={0}
onClick={() => selectJob(record.id)}