Compare commits

2 Commits

Author SHA1 Message Date
Dave
f5a32f2304 ESDP - List Parser - Notification fixes 0.0.6 2026-05-29 15:25:36 -04:00
Dave
655465a59a ESDP - List Parser - Notification fixes 0.0.5 2026-05-29 15:10:04 -04:00
11 changed files with 431 additions and 87 deletions

2
.gitignore vendored
View File

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

View File

@@ -19,6 +19,10 @@ asarUnpack:
win: win:
executableName: EMSDP executableName: EMSDP
icon: resources/icon.png icon: resources/icon.png
protocols:
- name: EMS Data Pump
schemes:
- esdp
azureSignOptions: azureSignOptions:
endpoint: https://eus.codesigning.azure.net endpoint: https://eus.codesigning.azure.net
certificateProfileName: ImEXRPS certificateProfileName: ImEXRPS
@@ -38,6 +42,7 @@ mac:
- NSMicrophoneUsageDescription: Application requests access to the device's microphone. - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- 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.
- NSUserNotificationAlertStyle: alert
- CFBundleURLTypes: - CFBundleURLTypes:
- CFBundleTypeRole: Viewer # More specific role for protocol handling - CFBundleTypeRole: Viewer # More specific role for protocol handling
CFBundleURLName: com.imex.esdp CFBundleURLName: com.imex.esdp

View File

@@ -1,7 +1,7 @@
{ {
"name": "esdp", "name": "esdp",
"productName": "EMS Uploader", "productName": "EMS Uploader",
"version": "0.0.4", "version": "0.0.6",
"description": "EMS Uploader", "description": "EMS Uploader",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "ImEX Systems Inc.", "author": "ImEX Systems Inc.",

View File

@@ -1,13 +1,13 @@
!macro customInstall !macro customInstall
; Register imexmedia protocol ; Register esdp protocol
WriteRegStr HKCR "imexmedia" "" "URL:ImEX Shop Partner Protocol" WriteRegStr HKCR "esdp" "" "URL:EMS Data Pump Protocol"
WriteRegStr HKCR "imexmedia" "URL Protocol" "" WriteRegStr HKCR "esdp" "URL Protocol" ""
WriteRegStr HKCR "imexmedia\\shell" "" "" WriteRegStr HKCR "esdp\\shell" "" ""
WriteRegStr HKCR "imexmedia\\shell\\open" "" "" WriteRegStr HKCR "esdp\\shell\\open" "" ""
WriteRegStr HKCR "imexmedia\\shell\\open\\command" "" '"$INSTDIR\ShopPartner.exe" "%1"' WriteRegStr HKCR "esdp\\shell\\open\\command" "" '"$INSTDIR\EMSDP.exe" "%1"'
!macroend !macroend
!macro customUnInstall !macro customUnInstall
; Remove imexmedia protocol ; Remove esdp protocol
DeleteRegKey HKCR "imexmedia" DeleteRegKey HKCR "esdp"
!macroend !macroend

View File

@@ -1,5 +1,6 @@
import { platform } from "@electron-toolkit/utils"; import { platform } from "@electron-toolkit/utils";
import { UUID } from "crypto"; import { UUID } from "crypto";
import { app } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
import fs from "fs"; import fs from "fs";
import _ from "lodash"; import _ from "lodash";
@@ -40,6 +41,65 @@ import getMainWindow from "../../util/getMainWindow";
import newWindow from "../../util/newWindow"; import newWindow from "../../util/newWindow";
import { createNotification, showNotification } from "../util/notification"; 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> { async function ImportJob(filepath: string): Promise<void> {
const parsedFilePath = path.parse(filepath); const parsedFilePath = path.parse(filepath);
const extensionlessFilePath = path.join( const extensionlessFilePath = path.join(
@@ -140,6 +200,11 @@ async function ImportJob(filepath: string): Promise<void> {
//Scrub the estimate //Scrub the estimate
const scrubResult = await ScrubEstimate({ job: jobObject }); const scrubResult = await ScrubEstimate({ job: jobObject });
if (!scrubResult) {
setAppProgressbar(-1);
return;
}
const scrubPdfURL = scrubResult?.pdfUrl; const scrubPdfURL = scrubResult?.pdfUrl;
const scrubHistoryJobId = scrubResult?.jobId; const scrubHistoryJobId = scrubResult?.jobId;
@@ -154,15 +219,6 @@ async function ImportJob(filepath: string): Promise<void> {
}); });
setAppProgressbar(-1); 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 openScrubHistoryItem = (): void => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
@@ -170,7 +226,9 @@ async function ImportJob(filepath: string): Promise<void> {
mainWindow.restore(); mainWindow.restore();
} }
mainWindow.show(); mainWindow.show();
mainWindow.moveTop();
mainWindow.focus(); mainWindow.focus();
app.focus({ steal: true });
if (scrubHistoryJobId) { if (scrubHistoryJobId) {
mainWindow.webContents.send(ipcTypes.toRenderer.scrub.openHistoryItem, { mainWindow.webContents.send(ipcTypes.toRenderer.scrub.openHistoryItem, {
jobId: scrubHistoryJobId, 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("click", openScrubHistoryItem);
uploadNotification.on("action", (e) => { uploadNotification.on("action", (event, actionIndex) => {
if (e.actionIndex === 0) { const selectedActionIndex = event.actionIndex ?? actionIndex;
openScrubHistoryItem(); notificationActionHandlers[selectedActionIndex]?.();
} else if (e.actionIndex === 1) {
if (scrubPdfURL) {
newWindow(scrubPdfURL);
}
}
}); });
uploadNotification.show(); uploadNotification.show();

View File

@@ -92,15 +92,19 @@ async function ScrubEstimate({
const esApiKey = store.get("settings.esApiKey") as string; const esApiKey = store.get("settings.esApiKey") as string;
if (!esApiKey || esApiKey.trim() === "") { if (!esApiKey || esApiKey.trim() === "") {
const message =
"You must have a valid and active API key set in order to scrub estimates.";
showNotification({ showNotification({
title: "No API Key Set", 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, { mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message: message,
"You must have a valid and active API key set in order to scrub estimates.",
}); });
return undefined;
} }
if (!job) { if (!job) {
@@ -183,24 +187,21 @@ async function ScrubEstimate({
: undefined; : undefined;
const scrubErrorMessage = getErrorMessage(responseMessage); const scrubErrorMessage = getErrorMessage(responseMessage);
if (status === 400) { const message =
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, { scrubErrorMessage ||
message: (status === 401
scrubErrorMessage || ? "Authentication with Estimate Scrubber failed."
"Error encountered sending estimate to Estimate Scrubber.", : "Error encountered sending estimate to Estimate Scrubber.");
});
} else if (status === 401) { showNotification({
showNotification({ title: "Error scrubbing estimate",
title: "Error scrubbing estimate", body: status ? `${message} (${status})` : message,
body: });
scrubErrorMessage || "Authentication with Estimate Scrubber failed.",
}); mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message,
});
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message:
scrubErrorMessage || "Authentication with Estimate Scrubber failed.",
});
}
return undefined; return undefined;
} }
} }

View File

@@ -12,9 +12,11 @@ import {
} from "electron"; } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
import { autoUpdater } from "electron-updater"; import { autoUpdater } from "electron-updater";
import { execFile } from "node:child_process";
import path, { join } from "path"; import path, { join } from "path";
import "source-map-support/register"; import "source-map-support/register";
import imexAppIcon from "../../resources/icon.png?asset"; import imexAppIcon from "../../resources/icon.png?asset";
import newWindow from "../util/newWindow";
import { import {
default as ErrorTypeCheck, default as ErrorTypeCheck,
@@ -54,7 +56,9 @@ log.transports.console.format =
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}"; "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}";
log.transports.file.maxSize = 50 * 1024 * 1024; // 50 MB log.transports.file.maxSize = 50 * 1024 * 1024; // 50 MB
const isMac: boolean = process.platform === "darwin"; const isMac: boolean = process.platform === "darwin";
const appId = "com.imex.esdp";
const protocol: string = "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 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 let isKeepAliveLaunch = false; // Track if launched via keep-alive
// Initialize the server // Initialize the server
@@ -68,7 +72,7 @@ if (!gotTheLock) {
app.quit(); // Quit the app if another instance is already running app.quit(); // Quit the app if another instance is already running
} }
function createWindow(): void { function createWindow(): BrowserWindow {
// Create the browser window. // Create the browser window.
const { width, height, x, y } = store.get("app.windowBounds") as { const { width, height, x, y } = store.get("app.windowBounds") as {
width: number; width: number;
@@ -459,19 +463,25 @@ function createWindow(): void {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }
return mainWindow;
} }
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
app.whenReady().then(async () => { app.whenReady().then(async () => {
if (!gotTheLock) {
return;
}
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
log.debug("App is ready, initializing shortcuts and protocol handlers."); log.debug("App is ready, initializing shortcuts and protocol handlers.");
if (platform.isWindows) { if (platform.isWindows) {
app.setAppUserModelId("esdp"); app.setAppUserModelId(appId);
} }
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
@@ -483,14 +493,16 @@ app.whenReady().then(async () => {
// remove so we can register each time as we run the app. // remove so we can register each time as we run the app.
app.removeAsDefaultProtocolClient(protocol); app.removeAsDefaultProtocolClient(protocol);
const protocolLaunchArgs = getProtocolLaunchArgs();
// If we are running a non-packaged version of the app && on windows // 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. // Set the path of electron.exe and your app.
// These two additional parameters are only available on windows. // These two additional parameters are only available on windows.
isDefaultProtocolClient = app.setAsDefaultProtocolClient( isDefaultProtocolClient = app.setAsDefaultProtocolClient(
protocol, protocol,
process.execPath, process.execPath,
[path.resolve(process.argv[1])], protocolLaunchArgs,
); );
} else { } else {
isDefaultProtocolClient = app.setAsDefaultProtocolClient(protocol); isDefaultProtocolClient = app.setAsDefaultProtocolClient(protocol);
@@ -501,6 +513,10 @@ app.whenReady().then(async () => {
log.warn("Failed to register protocol handler."); log.warn("Failed to register protocol handler.");
} }
if (platform.isWindows) {
await repairWindowsProtocolRegistration();
}
//Dynamically load ipcMain handlers once ready. //Dynamically load ipcMain handlers once ready.
try { try {
await import("./ipc/ipcMainConfig"); await import("./ipc/ipcMainConfig");
@@ -609,7 +625,13 @@ app.whenReady().then(async () => {
}); });
} }
//The update itself will run when the bodyshop record is queried to know what release channel to use. //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 () { app.on("activate", function () {
openMainWindow(); openMainWindow();
@@ -618,29 +640,14 @@ app.whenReady().then(async () => {
app.on("open-url", (event: Electron.Event, url: string) => { app.on("open-url", (event: Electron.Event, url: string) => {
event.preventDefault(); event.preventDefault();
if (url.startsWith(`${protocol}://keep-alive`)) { handleProtocolUrl(url);
log.info("Keep-alive protocol received.");
// Do nothing, whether app is running or not
return;
} else {
openInExplorer(url);
}
}); });
// Add this event handler for second instance // Add this event handler for second instance
app.on("second-instance", (_event: Electron.Event, argv: string[]) => { app.on("second-instance", (_event: Electron.Event, argv: string[]) => {
const url = argv.find((arg) => arg.startsWith(`${protocol}://`)); const url = argv.find((arg) => arg.startsWith(`${protocol}://`));
if (url) { if (url) {
if (url.startsWith(`${protocol}://keep-alive`)) { handleProtocolUrl(url);
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);
}
} }
// No action taken if no URL is provided // No action taken if no URL is provided
}); });
@@ -682,13 +689,195 @@ function preQuitMethods(): void {
isAppQuitting = true; 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 {
const isRunningElectronBinary =
path.basename(process.execPath).toLowerCase() === "electron.exe";
const isDevelopmentRuntime =
is.dev || process.defaultApp || !app.isPackaged || isRunningElectronBinary;
if (!isDevelopmentRuntime) {
return null;
}
const launchArgs = process.argv
.slice(1)
.filter((arg) => !arg.startsWith(`${protocol}://`));
if (launchArgs.length === 0) {
return [app.getAppPath()];
}
const normalizedLaunchArgs = launchArgs.map((arg) =>
arg.startsWith("-") ? arg : path.resolve(arg),
);
const hasAppPathArg = normalizedLaunchArgs.some(
(arg) => !arg.startsWith("-"),
);
return hasAppPathArg
? normalizedLaunchArgs
: [app.getAppPath(), ...normalizedLaunchArgs];
}
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(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show(); mainWindow.show();
} else { mainWindow.moveTop();
createWindow(); 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 { function openInExplorer(url: string): void {

View File

@@ -87,8 +87,8 @@ const SettingsWatcherPollingSet = async (
const { enabled, interval } = pollingSettings; const { enabled, interval } = pollingSettings;
Store.set("settings.polling", { enabled, interval }); Store.set("settings.polling", { enabled, interval });
await StopWatcher(); await StopWatcher({ notifyOnStopped: false });
await StartWatcher(); await StartWatcher({ notifyOnStarted: false });
return { enabled, interval }; return { enabled, interval };
}; };

View File

@@ -1,13 +1,23 @@
import { Notification, type NotificationConstructorOptions } from "electron"; import { Notification, type NotificationConstructorOptions } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
const retainedNotifications = new Set<Notification>();
const DEFAULT_RETENTION_MS = 30 * 60 * 1000;
function createNotification( function createNotification(
options: NotificationConstructorOptions, options: NotificationConstructorOptions,
context = options.title, context = options.title,
): Notification { ): Notification {
const notification = new Notification(options); const notification = new Notification(options);
retainedNotifications.add(notification);
const retentionTimer = setTimeout(() => {
retainedNotifications.delete(notification);
}, DEFAULT_RETENTION_MS);
retentionTimer.unref?.();
notification.on("failed", (_event, error) => { notification.on("failed", (_event, error) => {
retainedNotifications.delete(notification);
log.warn(`Notification failed${context ? ` (${context})` : ""}:`, error); log.warn(`Notification failed${context ? ` (${context})` : ""}:`, error);
}); });

View File

@@ -14,6 +14,11 @@ let watcherReady = false;
type StartWatcherOptions = { type StartWatcherOptions = {
notifyOnNoPaths?: boolean; notifyOnNoPaths?: boolean;
notifyOnStarted?: boolean;
};
type StopWatcherOptions = {
notifyOnStopped?: boolean;
}; };
function getValidWatcherPaths(filePaths: string[]): string[] { function getValidWatcherPaths(filePaths: string[]): string[] {
@@ -30,7 +35,7 @@ function getValidWatcherPaths(filePaths: string[]): string[] {
async function StartWatcher( async function StartWatcher(
options: StartWatcherOptions = {}, options: StartWatcherOptions = {},
): Promise<boolean> { ): Promise<boolean> {
const { notifyOnNoPaths = true } = options; const { notifyOnNoPaths = true, notifyOnStarted = true } = options;
const configuredFilePaths: string[] = store.get("settings.filepaths") || []; const configuredFilePaths: string[] = store.get("settings.filepaths") || [];
const filePaths = getValidWatcherPaths(configuredFilePaths); const filePaths = getValidWatcherPaths(configuredFilePaths);
@@ -116,7 +121,7 @@ async function StartWatcher(
// errorTypeCheck(error) // errorTypeCheck(error)
// ); // );
}) })
.on("ready", onWatcherReady); .on("ready", () => onWatcherReady({ notifyOnStarted }));
// .on("raw", function (event, path, details) { // .on("raw", function (event, path, details) {
// // This event should be triggered everytime something happens. // // This event should be triggered everytime something happens.
// // console.log("Raw event info:", event, path, details); // // console.log("Raw event info:", event, path, details);
@@ -139,21 +144,28 @@ function addWatcherPath(path: string | string[]): void {
} }
} }
function onWatcherReady(): void { function onWatcherReady({
notifyOnStarted,
}: {
notifyOnStarted: boolean;
}): void {
if (watcher) { if (watcher) {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
watcherReady = true; watcherReady = true;
showNotification({ if (notifyOnStarted) {
title: "Watcher Started", showNotification({
body: "Newly exported estimates will be automatically uploaded.", title: "Watcher Started",
}); body: "Newly exported estimates will be automatically uploaded.",
});
}
log.info("Confirmed watched paths:", watcher.getWatched()); log.info("Confirmed watched paths:", watcher.getWatched());
setWatcherTrayStatus(true); setWatcherTrayStatus(true);
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.started); mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.started);
} }
} }
async function StopWatcher(): Promise<boolean> { async function StopWatcher(options: StopWatcherOptions = {}): Promise<boolean> {
const { notifyOnStopped = true } = options;
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (watcher) { if (watcher) {
@@ -164,10 +176,12 @@ async function StopWatcher(): Promise<boolean> {
setWatcherTrayStatus(false); setWatcherTrayStatus(false);
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped); mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
showNotification({ if (notifyOnStopped) {
title: "Watcher Stopped", showNotification({
body: "Estimates will not be automatically uploaded.", title: "Watcher Stopped",
}); body: "Estimates will not be automatically uploaded.",
});
}
return true; return true;
} }
return false; return false;

View File

@@ -29,7 +29,15 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "antd"; } 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 { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router"; import { useNavigate, useSearchParams } from "react-router";
import { selectWatcherStatus } from "@renderer/redux/app.slice"; import { selectWatcherStatus } from "@renderer/redux/app.slice";
@@ -104,6 +112,7 @@ const Home: FC = () => {
null, null,
); );
const [loadingMore, setLoadingMore] = useState<boolean>(false); const [loadingMore, setLoadingMore] = useState<boolean>(false);
const historyItemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const routeSelectedJobId = searchParams.get("jobId")?.trim() || null; const routeSelectedJobId = searchParams.get("jobId")?.trim() || null;
const selectedJobId = routeSelectedJobId ?? manualSelectedJobId; const selectedJobId = routeSelectedJobId ?? manualSelectedJobId;
@@ -245,6 +254,27 @@ const Home: FC = () => {
await refresh(loadedPage + 1, true); await refresh(loadedPage + 1, true);
}, [hasMoreHistory, loadedPage, loading, loadingMore, refresh]); }, [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( const handleHistoryScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => { (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget; const target = event.currentTarget;
@@ -576,6 +606,9 @@ const Home: FC = () => {
return ( return (
<List.Item style={{ padding: "0 0 8px" }}> <List.Item style={{ padding: "0 0 8px" }}>
<div <div
ref={(element) =>
setHistoryItemRef(record.id, element)
}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => selectJob(record.id)} onClick={() => selectJob(record.id)}