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 => { 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 => { 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)); }); }