640 lines
20 KiB
TypeScript
640 lines
20 KiB
TypeScript
import { is, optimizer, platform } from "@electron-toolkit/utils";
|
|
import Sentry from "@sentry/electron/main";
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
globalShortcut,
|
|
ipcMain,
|
|
Menu,
|
|
nativeImage,
|
|
shell,
|
|
Tray,
|
|
} from "electron";
|
|
import log from "electron-log/main";
|
|
import { autoUpdater } from "electron-updater";
|
|
import path, { join } from "path";
|
|
import appIcon from "../../resources/icon.png?asset";
|
|
import {
|
|
default as ErrorTypeCheck,
|
|
default as errorTypeCheck,
|
|
} from "../util/errorTypeCheck";
|
|
import ipcTypes from "../util/ipcTypes.json";
|
|
import ImportJob from "./decoder/decoder";
|
|
import LocalServer from "./http-server/http-server";
|
|
import store from "./store/store";
|
|
import { checkForAppUpdates } from "./util/checkForAppUpdates";
|
|
import { getMainWindow } from "./util/toRenderer";
|
|
import { GetAllEnvFiles } from "./watcher/watcher";
|
|
import {
|
|
isKeepAliveAgentInstalled,
|
|
setupKeepAliveAgent,
|
|
} from "./setup-keep-alive-agent";
|
|
import {
|
|
isKeepAliveTaskInstalled,
|
|
setupKeepAliveTask,
|
|
} from "./setup-keep-alive-task";
|
|
|
|
Sentry.init({
|
|
dsn: "https://ba41d22656999a8c1fd63bcb7df98650@o492140.ingest.us.sentry.io/4509074139447296",
|
|
});
|
|
|
|
log.initialize();
|
|
const isMac: boolean = process.platform === "darwin";
|
|
const protocol: string = "imexmedia";
|
|
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
|
|
const localServer = new LocalServer();
|
|
const gotTheLock = app.requestSingleInstanceLock();
|
|
|
|
function createWindow(): void {
|
|
// Create the browser window.
|
|
const { width, height, x, y } = store.get("app.windowBounds") as {
|
|
width: number;
|
|
height: number;
|
|
x: number | undefined;
|
|
y: number | undefined;
|
|
};
|
|
|
|
const mainWindow = new BrowserWindow({
|
|
width,
|
|
height,
|
|
x,
|
|
y,
|
|
show: false, // Start hidden, show later if not keep-alive
|
|
minWidth: 600,
|
|
minHeight: 400,
|
|
//autoHideMenuBar: true,
|
|
...(process.platform === "linux" ? { icon: appIcon } : {}),
|
|
title: "Shop Partner",
|
|
webPreferences: {
|
|
preload: join(__dirname, "../preload/index.js"),
|
|
sandbox: false,
|
|
devTools: true,
|
|
},
|
|
});
|
|
|
|
const template: Electron.MenuItemConstructorOptions[] = [
|
|
// { role: 'appMenu' }
|
|
// @ts-ignore
|
|
...(isMac
|
|
? [
|
|
{
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" },
|
|
{ type: "separator" },
|
|
{ role: "services" },
|
|
{ type: "separator" },
|
|
{ role: "hide" },
|
|
{ role: "hideOthers" },
|
|
{ role: "unhide" },
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
},
|
|
]
|
|
: []),
|
|
// { role: 'fileMenu' }
|
|
{
|
|
label: "File",
|
|
submenu: [
|
|
// @ts-ignore
|
|
...(!isMac ? [{ role: "about" }] : []),
|
|
// @ts-ignore
|
|
isMac ? { role: "close" } : { role: "quit" },
|
|
],
|
|
},
|
|
// { role: 'editMenu' }
|
|
{
|
|
label: "Edit",
|
|
submenu: [
|
|
{ role: "undo" },
|
|
{ role: "redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut" },
|
|
{ role: "copy" },
|
|
{ role: "paste" },
|
|
// @ts-ignore
|
|
...(isMac
|
|
? [
|
|
{ role: "pasteAndMatchStyle" },
|
|
{ role: "delete" },
|
|
{ role: "selectAll" },
|
|
{ type: "separator" },
|
|
{
|
|
label: "Speech",
|
|
submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }],
|
|
},
|
|
]
|
|
: [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]),
|
|
],
|
|
},
|
|
// { role: 'viewMenu' }
|
|
{
|
|
label: "View",
|
|
// @ts-ignore
|
|
submenu: [
|
|
{ role: "reload" },
|
|
{ role: "forceReload" },
|
|
{ role: "toggleDevTools" },
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" },
|
|
{ role: "zoomIn" },
|
|
{ role: "zoomOut" },
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" },
|
|
],
|
|
},
|
|
{
|
|
label: "Application",
|
|
// @ts-ignore
|
|
submenu: [
|
|
{
|
|
label: "Open on Startup",
|
|
checked: store.get("app.openOnStartup") as boolean,
|
|
type: "checkbox",
|
|
click: (): void => {
|
|
const currentSetting = store.get("app.openOnStartup") as boolean;
|
|
store.set("app.openOnStartup", !currentSetting);
|
|
log.info("Open on startup set to", !currentSetting);
|
|
if (!import.meta.env.DEV) {
|
|
app.setLoginItemSettings({
|
|
enabled: true, //This is a windows only command. Updates the task manager and registry.
|
|
openAtLogin: !currentSetting,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: `Check for Updates (${app.getVersion()})`,
|
|
click: (): void => {
|
|
checkForAppUpdates();
|
|
},
|
|
},
|
|
{
|
|
label: "Development",
|
|
id: "development",
|
|
visible: import.meta.env.DEV,
|
|
submenu: [
|
|
{
|
|
label: "Connect to Test",
|
|
checked: store.get("app.isTest") as boolean,
|
|
type: "checkbox",
|
|
id: "toggleTest",
|
|
click: (): void => {
|
|
const currentSetting = store.get("app.isTest") as boolean;
|
|
store.set("app.isTest", !currentSetting);
|
|
log.info("Setting isTest to: ", !currentSetting);
|
|
app.relaunch(); // Relaunch the app
|
|
preQuitMethods(); //Quitting handlers aren't called. Manually execute to clean up the app.
|
|
app.exit(0); // Exit the current instance
|
|
},
|
|
},
|
|
{
|
|
label: "Check for updates",
|
|
click: (): void => {
|
|
checkForAppUpdates();
|
|
},
|
|
},
|
|
{
|
|
label: "Open Log File",
|
|
click: (): void => {
|
|
/* action for item 1 */
|
|
shell
|
|
.openPath(log.transports.file.getFile().path)
|
|
.catch((error) => {
|
|
log.error(
|
|
"Failed to open log file:",
|
|
errorTypeCheck(error),
|
|
);
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "Clear Log",
|
|
click: (): void => {
|
|
log.transports.file.getFile().clear();
|
|
},
|
|
},
|
|
{
|
|
label: "Open Config Folder",
|
|
click: (): void => {
|
|
shell.openPath(path.dirname(store.path)).catch((error) => {
|
|
log.error(
|
|
"Failed to open config folder:",
|
|
errorTypeCheck(error),
|
|
);
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "Log the Store",
|
|
click: (): void => {
|
|
log.debug(
|
|
"Store Contents" + JSON.stringify(store.store, null, 4),
|
|
);
|
|
},
|
|
},
|
|
{
|
|
type: "separator",
|
|
},
|
|
// {
|
|
// label: "Decode Hardcoded Estimate",
|
|
// click: (): void => {
|
|
// ImportJob(`C:\\EMS\\CCC\\9ee762f4.ENV`);
|
|
// },
|
|
// },
|
|
{
|
|
label: "Install Keep Alive",
|
|
enabled: true, // Default to enabled, update dynamically
|
|
click: async (): Promise<void> => {
|
|
try {
|
|
if (platform.isWindows) {
|
|
log.debug("Creating Windows keep-alive task");
|
|
await setupKeepAliveTask();
|
|
log.info("Successfully installed Windows keep-alive task");
|
|
} else if (platform.isMacOS) {
|
|
log.debug("Creating macOS keep-alive agent");
|
|
await setupKeepAliveAgent();
|
|
log.info("Successfully installed macOS keep-alive agent");
|
|
}
|
|
// Wait to ensure task/agent is registered
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
// Rebuild menu and update enabled state
|
|
await updateKeepAliveMenuItem();
|
|
} catch (error) {
|
|
log.error(
|
|
`Failed to install keep-alive: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
// Optionally notify user (e.g., via dialog or log)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Add All Estimates in watched directories",
|
|
click: (): void => {
|
|
GetAllEnvFiles().forEach((file) => ImportJob(file));
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
// { role: 'windowMenu' }
|
|
{
|
|
label: "Window",
|
|
submenu: [
|
|
{ role: "minimize" },
|
|
{ role: "zoom" },
|
|
// @ts-ignore
|
|
...(isMac
|
|
? [
|
|
{ type: "separator" },
|
|
{ role: "front" },
|
|
{ type: "separator" },
|
|
{ role: "window" },
|
|
]
|
|
: [{ role: "close" }]),
|
|
],
|
|
},
|
|
];
|
|
|
|
// Dynamically update Install Keep Alive enabled state
|
|
const updateKeepAliveMenuItem = async (): Promise<void> => {
|
|
try {
|
|
const isInstalled = platform.isWindows
|
|
? await isKeepAliveTaskInstalled()
|
|
: platform.isMacOS
|
|
? await isKeepAliveAgentInstalled()
|
|
: false;
|
|
const developmentMenu = template
|
|
.find((item) => item.label === "Application")
|
|
// @ts-ignore
|
|
?.submenu?.find((item: { id: string }) => item.id === "development")
|
|
?.submenu as Electron.MenuItemConstructorOptions[];
|
|
const keepAliveItem = developmentMenu?.find(
|
|
(item) => item.label === "Install Keep Alive",
|
|
);
|
|
if (keepAliveItem) {
|
|
keepAliveItem.enabled = !isInstalled; // Enable if not installed, disable if installed
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
log.debug(
|
|
`Updated Install Keep Alive menu item: enabled=${keepAliveItem.enabled}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
log.error(
|
|
`Error updating Keep Alive menu item: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const menu: Electron.Menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
// Update menu item enabled state on app start
|
|
updateKeepAliveMenuItem().catch((error) => {
|
|
log.error(
|
|
`Error updating Keep Alive menu item: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
});
|
|
|
|
// Register a global shortcut to show the hidden item
|
|
globalShortcut.register("CommandOrControl+Shift+T", () => {
|
|
console.log("Shortcut pressed! Revealing hidden item.");
|
|
|
|
// Update the menu to make the hidden item visible
|
|
// Find the menu item dynamically by its id
|
|
const fileMenu = template.find((item) => item.label === "Application");
|
|
// @ts-ignore
|
|
const hiddenItem = fileMenu?.submenu?.find(
|
|
(item: { id: string }) => item.id === "development",
|
|
);
|
|
//Adjust the development menu as well.
|
|
|
|
if (hiddenItem) {
|
|
hiddenItem.visible = true; // Update the visibility dynamically
|
|
const menu: Electron.Menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
});
|
|
|
|
// Store window properties for later
|
|
const storeWindowState = (): void => {
|
|
const [width, height] = mainWindow.getSize();
|
|
const [x, y] = mainWindow.getPosition();
|
|
store.set("app.windowBounds", { width, height, x, y });
|
|
};
|
|
mainWindow.on("resized", storeWindowState);
|
|
mainWindow.on("maximize", storeWindowState);
|
|
mainWindow.on("unmaximize", storeWindowState);
|
|
mainWindow.on("moved", storeWindowState);
|
|
|
|
mainWindow.on("ready-to-show", () => {
|
|
if (!isKeepAliveLaunch) {
|
|
mainWindow.show(); // Show only if not a keep-alive launch
|
|
}
|
|
//Start the HTTP server.
|
|
// Start the local HTTP server
|
|
try {
|
|
localServer.start();
|
|
} catch (error) {
|
|
log.error("Failed to start HTTP server:", errorTypeCheck(error));
|
|
}
|
|
});
|
|
|
|
mainWindow.on("close", (event: Electron.Event) => {
|
|
if (!isAppQuitting) {
|
|
event.preventDefault(); // Prevent the window from closing
|
|
mainWindow.hide();
|
|
}
|
|
});
|
|
|
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
|
shell.openExternal(details.url).catch((error) => {
|
|
log.error("Failed to open external URL:", errorTypeCheck(error));
|
|
});
|
|
return { action: "deny" };
|
|
});
|
|
|
|
// HMR for renderer base on electron-vite cli.
|
|
// Load the remote URL for development or the local html file for production.
|
|
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
|
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]).catch((error) => {
|
|
log.error("Failed to load URL:", errorTypeCheck(error));
|
|
});
|
|
} else {
|
|
mainWindow
|
|
.loadFile(join(__dirname, "../renderer/index.html"))
|
|
.catch((error) => {
|
|
log.error("Failed to load file:", errorTypeCheck(error));
|
|
});
|
|
}
|
|
if (import.meta.env.DEV) {
|
|
mainWindow.webContents.openDevTools();
|
|
}
|
|
}
|
|
|
|
if (!gotTheLock) {
|
|
app.quit(); // Quit the app if another instance is already running
|
|
}
|
|
// This method will be called when Electron has finished
|
|
// initialization and is ready to create browser windows.
|
|
// Some APIs can only be used after this event occurs.
|
|
app.whenReady().then(async () => {
|
|
// Default open or close DevTools by F12 in development
|
|
// and ignore CommandOrControl + R in production.
|
|
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
|
if (platform.isWindows) {
|
|
app.setAppUserModelId("Shop Partner");
|
|
}
|
|
|
|
app.on("browser-window-created", (_, window) => {
|
|
optimizer.watchWindowShortcuts(window);
|
|
});
|
|
|
|
let isDefaultProtocolClient: boolean;
|
|
|
|
// remove so we can register each time as we run the app.
|
|
app.removeAsDefaultProtocolClient(protocol);
|
|
|
|
// If we are running a non-packaged version of the app && on windows
|
|
if (process.env.NODE_ENV === "development" && process.platform === "win32") {
|
|
// 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])],
|
|
);
|
|
} else {
|
|
isDefaultProtocolClient = app.setAsDefaultProtocolClient(protocol);
|
|
}
|
|
if (isDefaultProtocolClient) {
|
|
log.info("Protocol handler registered successfully.");
|
|
} else {
|
|
log.warn("Failed to register protocol handler.");
|
|
}
|
|
|
|
// 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.");
|
|
// Do nothing if already running
|
|
return;
|
|
} else {
|
|
openInExplorer(url);
|
|
}
|
|
}
|
|
// No action taken if no URL is provided
|
|
});
|
|
|
|
//Dynamically load ipcMain handlers once ready.
|
|
try {
|
|
const { initializeCronTasks } = await import("./ipc/ipcMainConfig");
|
|
log.debug("Successfully loaded ipcMainConfig");
|
|
|
|
try {
|
|
await initializeCronTasks();
|
|
log.info("Cron tasks initialized successfully");
|
|
} catch (error) {
|
|
log.warn("Non-fatal: Failed to initialize cron tasks", {
|
|
...ErrorTypeCheck(error),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
log.error("Fatal: Failed to load ipcMainConfig", {
|
|
...ErrorTypeCheck(error),
|
|
});
|
|
throw error; // Adjust based on whether the app should continue
|
|
}
|
|
|
|
//Create Tray
|
|
const trayicon = nativeImage.createFromPath(appIcon);
|
|
const tray = new Tray(trayicon.resize({ width: 16 }));
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: "Show App",
|
|
click: (): void => {
|
|
openMainWindow();
|
|
},
|
|
},
|
|
{
|
|
label: "Quit",
|
|
click: (): void => {
|
|
app.quit(); // actually quit the app.
|
|
},
|
|
},
|
|
]);
|
|
|
|
tray.on("double-click", () => {
|
|
openMainWindow();
|
|
});
|
|
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
//Check for app updates.
|
|
autoUpdater.logger = log;
|
|
|
|
// if (import.meta.env.DEV) {
|
|
// // Useful for some dev/debugging tasks, but download can
|
|
// // not be validated because dev app is not signed
|
|
// autoUpdater.channel = "alpha";
|
|
// autoUpdater.updateConfigPath = path.join(
|
|
// __dirname,
|
|
// "../../dev-app-update.yml",
|
|
// );
|
|
// autoUpdater.forceDevUpdateConfig = true;
|
|
// //autoUpdater.autoDownload = false;
|
|
// }
|
|
|
|
autoUpdater.on("checking-for-update", () => {
|
|
log.info("Checking for update...");
|
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
|
mainWindow?.webContents.send(ipcTypes.toRenderer.updates.checking);
|
|
});
|
|
autoUpdater.on("update-available", (info) => {
|
|
log.info("Update available.", info);
|
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
|
mainWindow?.webContents.send(ipcTypes.toRenderer.updates.available, info);
|
|
});
|
|
autoUpdater.on("download-progress", (progress) => {
|
|
log.info(`Download speed: ${progress.bytesPerSecond}`);
|
|
log.info(`Downloaded ${progress.percent}%`);
|
|
log.info(`Total downloaded ${progress.transferred}/${progress.total}`);
|
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
|
mainWindow?.webContents.send(
|
|
ipcTypes.toRenderer.updates.downloading,
|
|
progress,
|
|
);
|
|
});
|
|
autoUpdater.on("update-downloaded", (info) => {
|
|
log.info("Update downloaded", info);
|
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
|
mainWindow?.webContents.send(ipcTypes.toRenderer.updates.downloaded, info);
|
|
});
|
|
|
|
// Check if launched with keep-alive protocol (Windows)
|
|
const args = process.argv.slice(1);
|
|
if (args.some((arg) => arg.startsWith(`${protocol}://keep-alive`))) {
|
|
isKeepAliveLaunch = true;
|
|
}
|
|
|
|
//The update itself will run when the bodyshop record is queried to know what release channel to use.
|
|
createWindow();
|
|
|
|
app.on("activate", function () {
|
|
openMainWindow();
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Quit when all windows are closed, except on macOS. There, it's common
|
|
// for applications and their menu bar to stay active until the user quits
|
|
// explicitly with Cmd + Q.
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit(); //Disable the quit.
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", (props) => {
|
|
console.log(props);
|
|
preQuitMethods();
|
|
});
|
|
|
|
//We need to hit the prequit methods from here as well to ensure the app quits and restarts.
|
|
ipcMain.on(ipcTypes.toMain.updates.apply, () => {
|
|
log.info("Applying update from renderer.");
|
|
preQuitMethods();
|
|
setImmediate(() => {
|
|
app.removeAllListeners("window-all-closed");
|
|
const mainWindow = getMainWindow();
|
|
if (mainWindow) mainWindow.close();
|
|
autoUpdater.quitAndInstall(false);
|
|
});
|
|
});
|
|
|
|
function preQuitMethods(): void {
|
|
localServer.stop();
|
|
const currentSetting = store.get("app.openOnStartup") as boolean;
|
|
if (!import.meta.env.DEV) {
|
|
app.setLoginItemSettings({
|
|
enabled: true, //This is a windows only command. Updates the task manager and registry.
|
|
openAtLogin: !currentSetting,
|
|
});
|
|
}
|
|
globalShortcut.unregisterAll();
|
|
isAppQuitting = true;
|
|
}
|
|
|
|
function openMainWindow(): void {
|
|
const mainWindow = BrowserWindow.getAllWindows()[0];
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
} else {
|
|
createWindow();
|
|
}
|
|
}
|
|
|
|
function openInExplorer(url: string): void {
|
|
const folderPath: string = decodeURIComponent(url.split(`${protocol}://`)[1]);
|
|
log.info("Opening folder in explorer", folderPath);
|
|
shell.openPath(folderPath).catch((error) => {
|
|
log.error("Failed to open folder in explorer:", errorTypeCheck(error));
|
|
});
|
|
}
|