feature/IO-3205-Paint-Scale-Integrations: Pre Protocol Handler Keep ALive.

This commit is contained in:
Dave Richer
2025-04-29 15:10:06 -04:00
parent 2c2652d07e
commit 122f4194f5
10 changed files with 291 additions and 22 deletions

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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

View File

@@ -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;

13
scripts/installer.nsh Normal file
View File

@@ -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

View File

@@ -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;

View File

@@ -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<void> => {
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<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", () => {
@@ -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));
});
}

View File

@@ -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<void> {
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.convenientbrands.bodyshop-desktop.keepalive</string>
<key>ProgramArguments</key>
<array>
<string>open</string>
<string>imexmedia://keep-alive</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
</dict>
</plist>`;
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<boolean> {
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
}

View File

@@ -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<void> {
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<boolean> {
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
}