Files
bodyshop-desktop/src/main/index.ts
2025-09-09 15:47:31 -07:00

653 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 imexAppIcon from "../../resources/icon.png?asset";
import romeAppIcon from "../../resources/ro-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";
import ensureWindowOnScreen from "./util/ensureWindowOnScreen";
const appIconToUse =
import.meta.env.VITE_COMPANY === "IMEX" ? imexAppIcon : romeAppIcon;
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;
};
// Validate window position is on screen
const { validX, validY } = ensureWindowOnScreen(x, y, width, height);
const mainWindow = new BrowserWindow({
width,
height,
x: validX,
y: validY,
show: false, // Start hidden, show later if not keep-alive
minWidth: 600,
minHeight: 400,
//autoHideMenuBar: true,
...(process.platform === "linux"
? {
icon: appIconToUse,
}
: {}),
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(appIconToUse);
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;
autoUpdater.allowDowngrade = true;
// 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));
});
}