diff --git a/.env.production b/.env.production deleted file mode 100644 index f7dd5a7..0000000 --- a/.env.production +++ /dev/null @@ -1,2 +0,0 @@ -VITE_FIREBASE_CONFIG={"apiKey":"AIzaSyDSezy-jGJreo7ulgpLdlpOwAOrgcaEkhU","authDomain":"imex-prod.firebaseapp.com","databaseURL":"https://imex-prod.firebaseio.com","projectId":"imex-prod","storageBucket":"imex-prod.appspot.com","messagingSenderId":"253497221485","appId":"1:253497221485:web:3c81c483b94db84b227a64","measurementId":"G-NTWBKG2L0M"} -VITE_GRAPHQL_ENDPOINT=https://db.imex.online/v1/graphql \ No newline at end of file diff --git a/.env.staging b/.env.staging deleted file mode 100644 index e69de29..0000000 diff --git a/electron-builder.imex.yml b/electron-builder.imex.yml index 3d85ad6..7c72f21 100644 --- a/electron-builder.imex.yml +++ b/electron-builder.imex.yml @@ -26,6 +26,8 @@ nsis: shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always + requestExecutionLevel: admin # Ensure elevated privileges + include: "scripts/installer.nsh" # Reference NSIS script from scripts directory mac: entitlementsInherit: build/entitlements.mac.plist category: public.app-category.business @@ -34,6 +36,11 @@ mac: - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + - CFBundleURLTypes: + - CFBundleTypeRole: Viewer # More specific role for protocol handling + CFBundleURLName: com.convenientbrands.bodyshop-desktop-imex + CFBundleURLSchemes: + - imexmedia target: - target: default arch: @@ -50,12 +57,16 @@ linux: - deb maintainer: electronjs.org category: Utility + desktop: + MimeType: x-scheme-handler/imexmedia; + afterInstall: | + # Install .desktop file for protocol handling + cp scripts/imex-shop-partner.desktop $HOME/.local/share/applications/ + update-desktop-database $HOME/.local/share/applications/ appImage: artifactName: ${name}-${version}.${ext} npmRebuild: false publish: provider: s3 bucket: imex-partner - region: ca-central-1 -# electronDownload: -# mirror: https://npmmirror.com/mirrors/electron/ + region: ca-central-1 \ No newline at end of file diff --git a/electron-builder.rome.yml b/electron-builder.rome.yml index 01ede46..eeb5f37 100644 --- a/electron-builder.rome.yml +++ b/electron-builder.rome.yml @@ -26,6 +26,8 @@ nsis: shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always + requestExecutionLevel: admin # Ensure elevated privileges + include: "scripts/installer.nsh" # Reference NSIS script from scripts directory mac: entitlementsInherit: build/entitlements.mac.plist category: public.app-category.business @@ -34,6 +36,11 @@ mac: - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + - CFBundleURLTypes: + - CFBundleTypeRole: Viewer # More specific role for protocol handling + CFBundleURLName: com.convenientbrands.bodyshop-desktop-rome + CFBundleURLSchemes: + - imexmedia target: - target: default arch: @@ -50,12 +57,16 @@ linux: - deb maintainer: electronjs.org category: Utility + desktop: + MimeType: x-scheme-handler/imexmedia; + afterInstall: | + # Install .desktop file for protocol handling + cp scripts/rome-shop-partner.desktop $HOME/.local/share/applications/ + update-desktop-database $HOME/.local/share/applications/ appImage: artifactName: ${name}-${version}.${ext} npmRebuild: false publish: provider: s3 bucket: rome-partner - region: us-east-2 -# electronDownload: -# mirror: https://npmmirror.com/mirrors/electron/ + region: us-east-2 \ No newline at end of file diff --git a/scripts/imex-shop-partner.desktop b/scripts/imex-shop-partner.desktop new file mode 100644 index 0000000..1085305 --- /dev/null +++ b/scripts/imex-shop-partner.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=ImEX Shop Partner +Exec=/opt/ImEX%20Shop%20Partner/ShopPartner %u +Type=Application +Terminal=false +Icon=imex-shop-partner +Categories=Utility; +MimeType=x-scheme-handler/imexmedia; \ No newline at end of file diff --git a/scripts/installer.nsh b/scripts/installer.nsh new file mode 100644 index 0000000..0333f74 --- /dev/null +++ b/scripts/installer.nsh @@ -0,0 +1,13 @@ +!macro customInstall + ; Register imexmedia protocol + WriteRegStr HKCR "imexmedia" "" "URL:ImEX Shop Partner Protocol" + WriteRegStr HKCR "imexmedia" "URL Protocol" "" + WriteRegStr HKCR "imexmedia\\shell" "" "" + WriteRegStr HKCR "imexmedia\\shell\\open" "" "" + WriteRegStr HKCR "imexmedia\\shell\\open\\command" "" '"$INSTDIR\ShopPartner.exe" "%1"' +!macroend + +!macro customUnInstall + ; Remove imexmedia protocol + DeleteRegKey HKCR "imexmedia" +!macroend \ No newline at end of file diff --git a/scripts/rome-shop-partner.desktop b/scripts/rome-shop-partner.desktop new file mode 100644 index 0000000..5fe6a89 --- /dev/null +++ b/scripts/rome-shop-partner.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Rome Shop Partner +Exec=/opt/Rome%20Shop%20Partner/ShopPartner %u +Type=Application +Terminal=false +Icon=rome-shop-partner +Categories=Utility; +MimeType=x-scheme-handler/imexmedia; \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index 1709dd6..711e21f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,6 +25,14 @@ 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", @@ -34,6 +42,7 @@ 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(); @@ -52,7 +61,7 @@ function createWindow(): void { height, x, y, - show: false, + show: false, // Start hidden, show later if not keep-alive minWidth: 600, minHeight: 400, //autoHideMenuBar: true, @@ -192,7 +201,14 @@ function createWindow(): void { label: "Open Log File", click: (): void => { /* action for item 1 */ - shell.openPath(log.transports.file.getFile().path); + shell + .openPath(log.transports.file.getFile().path) + .catch((error) => { + log.error( + "Failed to open log file:", + errorTypeCheck(error), + ); + }); }, }, { @@ -204,7 +220,12 @@ function createWindow(): void { { label: "Open Config Folder", click: (): void => { - shell.openPath(path.dirname(store.path)); + shell.openPath(path.dirname(store.path)).catch((error) => { + log.error( + "Failed to open config folder:", + errorTypeCheck(error), + ); + }); }, }, { @@ -218,13 +239,34 @@ function createWindow(): void { { 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) { + await setupKeepAliveTask(); + log.info("Successfully installed Windows keep-alive task"); + } else if (platform.isMacOS) { + await setupKeepAliveAgent(); + log.info("Successfully installed macOS keep-alive agent"); + } + // 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 => { @@ -254,8 +296,45 @@ function createWindow(): void { }, ]; + // 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", () => { @@ -266,7 +345,7 @@ function createWindow(): void { const fileMenu = template.find((item) => item.label === "Application"); // @ts-ignore const hiddenItem = fileMenu?.submenu?.find( - (item) => item.id === "development", + (item: { id: string }) => item.id === "development", ); //Adjust the development menu as well. @@ -289,7 +368,9 @@ function createWindow(): void { mainWindow.on("moved", storeWindowState); mainWindow.on("ready-to-show", () => { - mainWindow.show(); + if (!isKeepAliveLaunch) { + mainWindow.show(); // Show only if not a keep-alive launch + } //Start the HTTP server. // Start the local HTTP server try { @@ -307,16 +388,22 @@ function createWindow(): void { }); mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url); + 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"]); + 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")); + 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(); @@ -341,7 +428,8 @@ app.whenReady().then(async () => { optimizer.watchWindowShortcuts(window); }); - let isDefaultProtocolClient; + let isDefaultProtocolClient: boolean; + // remove so we can register each time as we run the app. app.removeAsDefaultProtocolClient(protocol); @@ -369,11 +457,15 @@ app.whenReady().then(async () => { openMainWindow(); const url = argv.find((arg) => arg.startsWith(`${protocol}://`)); if (url) { - openInExplorer(url); + if (url.startsWith(`${protocol}://keep-alive`)) { + log.info("Keep-alive protocol received, app is already running."); + // Do nothing if already running + } else { + openInExplorer(url); + } } }); - //Dynamically load ipcMain handlers once ready. try { const module = await import("./ipc/ipcMainConfig"); @@ -453,6 +545,12 @@ app.whenReady().then(async () => { 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(); @@ -464,7 +562,13 @@ app.whenReady().then(async () => { app.on("open-url", (event: Electron.Event, url: string) => { event.preventDefault(); //Don't do anything for now. We just want to open the app. - openInExplorer(url); + if (url.startsWith(`${protocol}://keep-alive`)) { + log.info("Keep-alive protocol received."); + isKeepAliveLaunch = true; + openMainWindow(); + } else { + openInExplorer(url); + } }); // Quit when all windows are closed, except on macOS. There, it's common @@ -518,5 +622,7 @@ function openMainWindow(): void { function openInExplorer(url: string): void { const folderPath: string = decodeURIComponent(url.split(`${protocol}://`)[1]); log.info("Opening folder in explorer", folderPath); - shell.openPath(folderPath); + shell.openPath(folderPath).catch((error) => { + log.error("Failed to open folder in explorer:", errorTypeCheck(error)); + }); } diff --git a/src/main/setup-keep-alive-agent.ts b/src/main/setup-keep-alive-agent.ts new file mode 100644 index 0000000..911be5b --- /dev/null +++ b/src/main/setup-keep-alive-agent.ts @@ -0,0 +1,68 @@ +import { promises as fs } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +import { exec } from "child_process"; +import { promisify } from "util"; +import log from "electron-log/main"; + +const execPromise = promisify(exec); + +export async function setupKeepAliveAgent(): Promise { + const plistContent = ` + + + + Label + com.convenientbrands.bodyshop-desktop.keepalive + ProgramArguments + + open + imexmedia://keep-alive + + RunAtLoad + + StartInterval + 300 + +`; + + const plistPath = join( + homedir(), + "Library/LaunchAgents/com.convenientbrands.bodyshop-desktop.keepalive.plist", + ); + + try { + await fs.writeFile(plistPath, plistContent); + const { stdout, stderr } = await execPromise(`launchctl load ${plistPath}`); + log.info(`Launch agent created and loaded: ${stdout}`); + if (stderr) log.warn(`Launch agent stderr: ${stderr}`); + } catch (error) { + log.error(`Error setting up launch agent: ${error instanceof Error ? error.message : String(error)}`); + throw error; // Rethrow to allow caller to handle + } +} + +export async function isKeepAliveAgentInstalled(): Promise { + const plistPath = join( + homedir(), + "Library/LaunchAgents/com.convenientbrands.bodyshop-desktop.keepalive.plist", + ); + const maxRetries = 3; + const retryDelay = 500; // 500ms delay between retries + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await fs.access(plistPath, fs.constants.F_OK); + const { stdout } = await execPromise(`launchctl list | grep com.convenientbrands.bodyshop-desktop.keepalive`); + return !!stdout; // Return true if plist exists and agent is loaded + } catch (error) { + log.debug(`Launch agent not found (attempt ${attempt}/${maxRetries}): ${error instanceof Error ? error.message : String(error)}`); + if (attempt === maxRetries) { + return false; // Return false after all retries fail + } + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + return false; // Fallback return +} \ No newline at end of file diff --git a/src/main/setup-keep-alive-task.ts b/src/main/setup-keep-alive-task.ts new file mode 100644 index 0000000..afcfbb0 --- /dev/null +++ b/src/main/setup-keep-alive-task.ts @@ -0,0 +1,46 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import log from "electron-log/main"; + +const execPromise = promisify(exec); + +export async function setupKeepAliveTask(): Promise { + const taskName = "ImEXShopPartnerKeepAlive"; + const protocolUrl = "imexmedia://keep-alive"; + // Use PowerShell with -ExecutionPolicy Bypass to open the URL + const command = `powershell.exe -ExecutionPolicy Bypass -Command "Start-Process '${protocolUrl}'"`; + // Escape quotes for schtasks /tr parameter + const escapedCommand = command.replace(/"/g, '\\"'); + + const schtasksCommand = `schtasks /create /tn "${taskName}" /tr "${escapedCommand}" /sc minute /mo 5 /f`; + + try { + const { stdout, stderr } = await execPromise(schtasksCommand); + log.info(`Scheduled task created: ${stdout}`); + if (stderr) log.warn(`Scheduled task stderr: ${stderr}`); + } catch (error) { + log.error(`Error creating scheduled task: ${error instanceof Error ? error.message : String(error)}`); + throw error; // Rethrow to allow caller to handle + } +} + +export async function isKeepAliveTaskInstalled(): Promise { + const taskName = "ImEXShopPartnerKeepAlive"; + const maxRetries = 3; + const retryDelay = 500; // 500ms delay between retries + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const { stdout } = await execPromise(`schtasks /query /tn "${taskName}"`); + return !!stdout; // Return true if task exists + } catch (error) { + log.debug(`Scheduled task ${taskName} not found (attempt ${attempt}/${maxRetries}): ${error instanceof Error ? error.message : String(error)}`); + if (attempt === maxRetries) { + return false; // Return false after all retries fail + } + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + return false; // Fallback return +} \ No newline at end of file