Compare commits
2 Commits
feature/IO
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a32f2304 | ||
|
|
655465a59a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ override.tf.json
|
|||||||
.terraformrc
|
.terraformrc
|
||||||
terraform.rc
|
terraform.rc
|
||||||
/.eslintcache
|
/.eslintcache
|
||||||
|
/docs
|
||||||
|
/.idea
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user