diff --git a/.env.imex b/.env.imex index 1d6ab25..3bd811e 100644 --- a/.env.imex +++ b/.env.imex @@ -1,7 +1,14 @@ -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 -VITE_FIREBASE_CONFIG_TEST={ "apiKey":"AIzaSyBw7_GTy7GtQyfkIRPVrWHEGKfcqeyXw0c", "authDomain":"imex-test.firebaseapp.com", "projectId":"imex-test", "storageBucket":"imex-test.appspot.com", "messagingSenderId":"991923618608", "appId":"1:991923618608:web:633437569cdad78299bef5", "measurementId":"G-TW0XLZEH18"} -VITE_GRAPHQL_ENDPOINT_TEST=https://db.test.bodyshop.app/v1/graphql VITE_COMPANY=IMEX + +# Fire Base Config +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_FIREBASE_CONFIG_TEST={ "apiKey":"AIzaSyBw7_GTy7GtQyfkIRPVrWHEGKfcqeyXw0c", "authDomain":"imex-test.firebaseapp.com", "projectId":"imex-test", "storageBucket":"imex-test.appspot.com", "messagingSenderId":"991923618608", "appId":"1:991923618608:web:633437569cdad78299bef5", "measurementId":"G-TW0XLZEH18"} +# GraphQL Config +VITE_GRAPHQL_ENDPOINT=https://db.imex.online/v1/graphql +VITE_GRAPHQL_ENDPOINT_TEST=https://db.test.bodyshop.app/v1/graphql +# Front End URL VITE_FE_URL=https://imex.online -VITE_FE_URL_TEST=https://test.imex.online \ No newline at end of file +VITE_FE_URL_TEST=https://test.imex.online +# API Url +VITE_API_URL="https://api.imex.online" +VITE_API_TEST_URL="https://test.api.imex.online" \ No newline at end of file diff --git a/.env.local b/.env.local index 5ca67ff..b364357 100644 --- a/.env.local +++ b/.env.local @@ -4,4 +4,6 @@ VITE_FIREBASE_CONFIG_TEST={ "apiKey":"AIzaSyBw7_GTy7GtQyfkIRPVrWHEGKfcqeyXw0c" VITE_GRAPHQL_ENDPOINT_TEST=https://db.test.bodyshop.app/v1/graphql VITE_COMPANY=IMEX VITE_FE_URL=https://imex.online -VITE_FE_URL_TEST=https://test.imex.online \ No newline at end of file +VITE_FE_URL_TEST=https://test.imex.online +VITE_API_URL="http://localhost:4000" +VITE_API_TEST_URL="http://api.test.imex.online" \ No newline at end of file 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.rome b/.env.rome index 4180038..9695644 100644 --- a/.env.rome +++ b/.env.rome @@ -1,7 +1,14 @@ -VITE_FIREBASE_CONFIG={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"} -VITE_GRAPHQL_ENDPOINT=https://db.romeonline.io/v1/graphql -VITE_FIREBASE_CONFIG_TEST={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"} -VITE_GRAPHQL_ENDPOINT_TEST=https://db.test.romeonline.io/v1/graphql VITE_COMPANY=ROME + +# Fire Base Config +VITE_FIREBASE_CONFIG={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"} +VITE_FIREBASE_CONFIG_TEST={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"} +# GraphQL Config +VITE_GRAPHQL_ENDPOINT=https://db.romeonline.io/v1/graphql +VITE_GRAPHQL_ENDPOINT_TEST=https://db.test.romeonline.io/v1/graphql +# Front End URL VITE_FE_URL=https://romeonline.io -VITE_FE_URL_TEST=https://test.romeonline.io \ No newline at end of file +VITE_FE_URL_TEST=https://test.romeonline.io +# API Url +VITE_API_URL="https://api.romeonline.io" +VITE_API_TEST_URL="https://test.api.romeonline.io" \ No newline at end of file diff --git a/.env.staging b/.env.staging deleted file mode 100644 index e69de29..0000000 diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml index 0cb5647..4647599 100644 --- a/.idea/material_theme_project_new.xml +++ b/.idea/material_theme_project_new.xml @@ -3,7 +3,9 @@ diff --git a/electron-builder.imex.yml b/electron-builder.imex.yml index 3d85ad6..5fde59c 100644 --- a/electron-builder.imex.yml +++ b/electron-builder.imex.yml @@ -7,6 +7,7 @@ directories: buildResources: build files: - "!**/.vscode/*" + - "!**/.idea/*" - "!src/*" - "!electron.vite.config.{js,ts,mjs,cjs}" - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" @@ -26,6 +27,7 @@ nsis: shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always + 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,11 @@ linux: - deb maintainer: electronjs.org category: Utility + desktop: scripts/imex-shop-partner.desktop 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..b01d3cd 100644 --- a/electron-builder.rome.yml +++ b/electron-builder.rome.yml @@ -7,6 +7,7 @@ directories: buildResources: build files: - "!**/.vscode/*" + - "!**/.idea/*" - "!src/*" - "!electron.vite.config.{js,ts,mjs,cjs}" - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" @@ -26,6 +27,7 @@ nsis: shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always + 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,11 @@ linux: - deb maintainer: electronjs.org category: Utility + desktop: scripts/rome-shop-partner.desktop 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/package-lock.json b/package-lock.json index 758e375..745f7c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bodyshop-desktop", - "version": "0.0.1-alpha.6", + "version": "0.0.1-alpha.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bodyshop-desktop", - "version": "0.0.1-alpha.6", + "version": "0.0.1-alpha.8", "hasInstallScript": true, "dependencies": { "@apollo/client": "^3.13.6", @@ -14,6 +14,7 @@ "@electron-toolkit/utils": "^4.0.0", "@sentry/electron": "^6.5.0", "@sentry/vite-plugin": "^3.3.1", + "axios": "^1.9.0", "dayjs": "^1.11.13", "electron-log": "^5.3.3", "electron-store": "^8.2.0", @@ -31,8 +32,10 @@ "@types/express": "^5.0.1", "@types/lodash": "^4.17.16", "@types/node": "^22.14.0", + "@types/node-cron": "^3.0.11", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.2", + "@types/xml2js": "^0.4.14", "@vitejs/plugin-react": "^4.3.4", "antd": "^5.24.6", "chokidar": "^4.0.3", @@ -52,6 +55,7 @@ "graphql-request": "^7.1.2", "i18next": "^24.2.3", "lodash": "^4.17.21", + "node-cron": "^3.0.3", "playwright": "^1.51.1", "prettier": "^3.5.3", "react": "^19.1.0", @@ -62,7 +66,9 @@ "react-router": "^7.5.0", "redux-logger": "^3.0.6", "typescript": "^5.8.3", - "vite": "6.2.6" + "vite": "6.2.6", + "xml2js": "^0.6.2", + "xmlbuilder2": "^3.1.1" } }, "node_modules/@ampproject/remapping": { @@ -2695,6 +2701,58 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@oozcitak/dom": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz", + "integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.8", + "@oozcitak/url": "1.0.4", + "@oozcitak/util": "8.3.8" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz", + "integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/util": "8.3.8" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz", + "integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.8", + "@oozcitak/util": "8.3.8" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "8.3.8", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz", + "integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -4322,13 +4380,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", @@ -4459,6 +4510,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.6.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", @@ -4587,6 +4645,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5627,7 +5695,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5666,6 +5733,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6138,7 +6216,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6399,7 +6476,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6943,7 +7019,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7189,7 +7264,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7706,7 +7780,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7716,7 +7789,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7754,7 +7826,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7767,7 +7838,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8144,6 +8214,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -8528,6 +8612,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -8578,7 +8682,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8594,7 +8697,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8604,7 +8706,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8798,7 +8899,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8823,7 +8923,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8977,7 +9076,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9117,7 +9215,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9130,7 +9227,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -10561,7 +10657,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11026,6 +11121,19 @@ "node": ">=10" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dev": true, + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12851,13 +12959,12 @@ } }, "node_modules/react-router": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz", - "integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.3.tgz", + "integrity": "sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw==", "dev": true, "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" @@ -14773,6 +14880,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -15149,6 +15266,30 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -15159,6 +15300,53 @@ "node": ">=8.0" } }, + "node_modules/xmlbuilder2": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz", + "integrity": "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "1.15.10", + "@oozcitak/infra": "1.0.8", + "@oozcitak/util": "8.3.8", + "js-yaml": "3.14.1" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/xmlbuilder2/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/xmlbuilder2/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/xmlbuilder2/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index f751d16..315ceec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bodyshop-desktop", - "version": "0.0.1-alpha.7", + "version": "0.0.1-alpha.8", "description": "Shop Management System Partner", "main": "./out/main/index.js", "author": "Convenient Brands, LLC", @@ -17,11 +17,13 @@ "build:rome": "electron-vite build --mode rome && electron-builder --config electron-builder.rome.yml", "build:imex:publish": "electron-vite build --mode imex && electron-builder --config electron-builder.imex.yml --publish always", "build:rome:publish": "electron-vite build --mode rome && electron-builder --config electron-builder.rome.yml --publish always", + "build:imex:linux": "electron-vite build --mode imex && electron-builder --config electron-builder.imex.yml --linux", + "build:rome:linux": "electron-vite build --mode rome && electron-builder --config electron-builder.rome.yml --linux", "postinstall": "electron-builder install-app-deps", - "build:unpack": "npm run build && electron-builder --dir", - "build:win": "npm run build && electron-builder --win", - "build:mac": "npm run build && electron-builder --mac", - "build:linux": "npm run build && electron-builder --linux" + "build:unpack": "electron-vite build --mode imex && electron-builder --dir", + "build:win": "electron-vite build --mode imex && electron-builder --win", + "build:mac": "electron-vite build --mode imex && electron-builder --mac", + "build:linux": "electron-vite build --mode imex && electron-builder --linux" }, "dependencies": { "@apollo/client": "^3.13.6", @@ -29,6 +31,7 @@ "@electron-toolkit/utils": "^4.0.0", "@sentry/electron": "^6.5.0", "@sentry/vite-plugin": "^3.3.1", + "axios": "^1.9.0", "dayjs": "^1.11.13", "electron-log": "^5.3.3", "electron-store": "^8.2.0", @@ -46,8 +49,10 @@ "@types/express": "^5.0.1", "@types/lodash": "^4.17.16", "@types/node": "^22.14.0", + "@types/node-cron": "^3.0.11", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.2", + "@types/xml2js": "^0.4.14", "@vitejs/plugin-react": "^4.3.4", "antd": "^5.24.6", "chokidar": "^4.0.3", @@ -77,6 +82,9 @@ "react-router": "^7.5.0", "redux-logger": "^3.0.6", "typescript": "^5.8.3", - "vite": "6.2.6" + "vite": "6.2.6", + "xml2js": "^0.6.2", + "xmlbuilder2": "^3.1.1", + "node-cron": "^3.0.3" } } 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 100fb5d..d60d8e3 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,38 @@ 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) { + 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 => { @@ -254,8 +300,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 +349,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 +372,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 +392,24 @@ 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 +434,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); @@ -365,25 +459,30 @@ app.whenReady().then(async () => { // Add this event handler for second instance app.on("second-instance", (_event: Electron.Event, argv: string[]) => { - // Someone tried to run a second instance, we should focus our window - 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 + return; // Skip openMainWindow to avoid focusing the window + } else { + openInExplorer(url); + } + } else { + openMainWindow(); // Focus window if no URL } }); //Dynamically load ipcMain handlers once ready. try { - // Replace 'path/to/your/file' with the actual path to your file const module = await import("./ipc/ipcMainConfig"); + log.debug("Successfully loaded ipcMainConfig"); - // You can now use anything exported from the module - // For example: - // module.someFunction() - log.debug("Successfully loaded ipcMainConfig", module); + // Initialize cron tasks after loading ipcMainConfig + await module.initializeCronTasks(); + log.info("Cron tasks initialized successfully"); } catch (error) { - log.error("Failed to load ipcMainConfig", { + log.error("Failed to load ipcMainConfig or initialize cron tasks", { ...ErrorTypeCheck(error), }); } @@ -453,6 +552,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 +569,17 @@ 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."); + if (BrowserWindow.getAllWindows().length === 0) { + isKeepAliveLaunch = true; + openMainWindow(); // Launch app if not running + } + // Do nothing if already running + return; // Skip openMainWindow to avoid focusing the window + } else { + openInExplorer(url); + } }); // Quit when all windows are closed, except on macOS. There, it's common @@ -518,5 +633,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/ipc/ipcMainConfig.ts b/src/main/ipc/ipcMainConfig.ts index 7a6f3ad..fba4f99 100644 --- a/src/main/ipc/ipcMainConfig.ts +++ b/src/main/ipc/ipcMainConfig.ts @@ -7,6 +7,14 @@ import ImportJob from "../decoder/decoder"; import store from "../store/store"; import { StartWatcher, StopWatcher } from "../watcher/watcher"; import { + SettingEmsOutFilePathGet, + SettingEmsOutFilePathSet, + SettingsPaintScaleInputConfigsGet, + SettingsPaintScaleInputConfigsSet, + SettingsPaintScaleInputPathSet, + SettingsPaintScaleOutputConfigsGet, + SettingsPaintScaleOutputConfigsSet, + SettingsPaintScaleOutputPathSet, SettingsPpcFilePathGet, SettingsPpcFilePathSet, SettingsWatchedFilePathsAdd, @@ -14,22 +22,38 @@ import { SettingsWatchedFilePathsRemove, SettingsWatcherPollingGet, SettingsWatcherPollingSet, - SettingEmsOutFilePathSet, - SettingEmsOutFilePathGet, } from "./ipcMainHandler.settings"; import { ipcMainHandleAuthStateChanged, ipMainHandleResetPassword, } from "./ipcMainHandler.user"; +import cron from "node-cron"; +import { PaintScaleConfig, PaintScaleType } from "../../util/types/paintScale"; +import { ppgInputHandler, ppgOutputHandler } from "./paintScaleHandlers/PPG"; + +const initializeCronTasks = async () => { + try { + // Fetch input and output configurations + const inputConfigs = await SettingsPaintScaleInputConfigsGet(); + const outputConfigs = await SettingsPaintScaleOutputConfigsGet(); + + // Start input cron tasks + await handlePaintScaleInputCron(inputConfigs); + log.info("Initialized input cron tasks on app startup"); + + // Start output cron tasks + await handlePaintScaleOutputCron(outputConfigs); + log.info("Initialized output cron tasks on app startup"); + } catch (error) { + log.error("Error initializing cron tasks on app startup:", error); + } +}; // Log all IPC messages and their payloads const logIpcMessages = (): void => { - // Get all message types from ipcTypes.toMain Object.keys(ipcTypes.toMain).forEach((key) => { const messageType = ipcTypes.toMain[key]; - - // Wrap the original handler with our logging - const originalHandler = ipcMain.listeners(messageType)[0]; + const originalHandler = ipcMain.listeners(messageType)?.[0]; if (originalHandler) { ipcMain.removeAllListeners(messageType); } @@ -40,7 +64,6 @@ const logIpcMessages = (): void => { "color: green", payload, ); - // Call original handler if it existed if (originalHandler) { originalHandler(event, payload); } @@ -48,21 +71,91 @@ const logIpcMessages = (): void => { }); }; +// Input handler map +const inputTypeHandlers: Partial< + Record Promise> +> = { + [PaintScaleType.PPG]: ppgInputHandler, + // Add other input type handlers as needed +}; + +// Output handler map +const outputTypeHandlers: Partial< + Record Promise> +> = { + [PaintScaleType.PPG]: ppgOutputHandler, + // Add other output type handlers as needed +}; + +// Default handler for unsupported types +const defaultHandler = async (config: PaintScaleConfig) => { + log.debug( + `No handler defined for type ${config.type} in config ${config.id}`, + ); +}; + +// Input cron job management +let inputCronTasks: { [id: string]: cron.ScheduledTask } = {}; + +const handlePaintScaleInputCron = async (configs: PaintScaleConfig[]) => { + Object.values(inputCronTasks).forEach((task) => task.stop()); + inputCronTasks = {}; + + const validConfigs = configs.filter( + (config) => config.path && config.path.trim() !== "", + ); + + validConfigs.forEach((config) => { + const cronExpression = `*/${config.pollingInterval} * * * *`; + inputCronTasks[config.id] = cron.schedule(cronExpression, async () => { + const handler = inputTypeHandlers[config.type] || defaultHandler; + await handler(config); + }); + log.info( + `Started input cron task for config ${config.id} (type: ${config.type}) with interval ${config.pollingInterval}m`, + ); + }); +}; + +// Output cron job management +let outputCronTasks: { [id: string]: cron.ScheduledTask } = {}; + +const handlePaintScaleOutputCron = async (configs: PaintScaleConfig[]) => { + Object.values(outputCronTasks).forEach((task) => task.stop()); + outputCronTasks = {}; + + const validConfigs = configs.filter( + (config) => config.path && config.path.trim() !== "", + ); + + validConfigs.forEach((config) => { + const cronExpression = `*/${config.pollingInterval} * * * *`; + outputCronTasks[config.id] = cron.schedule(cronExpression, async () => { + const handler = outputTypeHandlers[config.type] || defaultHandler; + await handler(config); + }); + log.info( + `Started output cron task for config ${config.id} (type: ${config.type}) with interval ${config.pollingInterval}m`, + ); + }); +}; + +// Existing IPC handlers... + ipcMain.on(ipcTypes.toMain.test, () => console.log("** Verify that ipcMain is loaded and working."), ); -//Auth handler +// Auth handler ipcMain.on(ipcTypes.toMain.authStateChanged, ipcMainHandleAuthStateChanged); ipcMain.on(ipcTypes.toMain.user.resetPassword, ipMainHandleResetPassword); -//Add debug handlers if in development +// Add debug handlers if in development if (import.meta.env.DEV) { log.debug("[IPC Debug Functions] Adding Debug Handlers"); ipcMain.on(ipcTypes.toMain.debug.decodeEstimate, async (): Promise => { const relativeEmsFilepath = `_reference/ems/MPI_1/3698420.ENV`; - // Get the app's root directory and create an absolute path const rootDir = app.getAppPath(); const absoluteFilepath = path.join(rootDir, relativeEmsFilepath); @@ -76,7 +169,7 @@ if (import.meta.env.DEV) { }); } -//Settings Handlers +// Settings Handlers ipcMain.handle( ipcTypes.toMain.settings.filepaths.get, SettingsWatchedFilePathsGet, @@ -109,22 +202,77 @@ ipcMain.handle( SettingEmsOutFilePathSet, ); +// Paint Scale Input Settings Handlers +ipcMain.handle( + ipcTypes.toMain.settings.paintScale.getInputConfigs, + SettingsPaintScaleInputConfigsGet, +); +ipcMain.handle( + ipcTypes.toMain.settings.paintScale.setInputConfigs, + SettingsPaintScaleInputConfigsSet, +); +ipcMain.handle( + ipcTypes.toMain.settings.paintScale.setInputPath, + SettingsPaintScaleInputPathSet, +); + +// Paint Scale Output Settings Handlers +ipcMain.handle( + ipcTypes.toMain.settings.paintScale.getOutputConfigs, + SettingsPaintScaleOutputConfigsGet, +); +ipcMain.handle( + ipcTypes.toMain.settings.paintScale.setOutputConfigs, + SettingsPaintScaleOutputConfigsSet, +); +ipcMain.handle( + ipcTypes.toMain.settings.paintScale.setOutputPath, + SettingsPaintScaleOutputPathSet, +); + +// IPC handlers for updating paint scale cron +ipcMain.on( + ipcTypes.toMain.settings.paintScale.updateInputCron, + (_event, configs: PaintScaleConfig[]) => { + handlePaintScaleInputCron(configs).catch((error) => { + log.error(`Error handling paint scale input cron for configs: ${error}`); + }); + }, +); + +ipcMain.on( + ipcTypes.toMain.settings.paintScale.updateOutputCron, + (_event, configs: PaintScaleConfig[]) => { + handlePaintScaleOutputCron(configs).catch((error) => { + log.error(`Error handling paint scale output cron for configs: ${error}`); + }); + }, +); + ipcMain.handle(ipcTypes.toMain.user.getActiveShop, () => { return store.get("app.bodyshop.shopname"); }); -//Watcher Handlers +// Watcher Handlers ipcMain.on(ipcTypes.toMain.watcher.start, () => { - StartWatcher(); + StartWatcher().catch((error) => { + log.error("Error starting watcher:", error); + }); }); ipcMain.on(ipcTypes.toMain.watcher.stop, () => { - StopWatcher(); + StopWatcher().catch((error) => { + log.error("Error stopping watcher:", error); + }); }); ipcMain.on(ipcTypes.toMain.updates.download, () => { log.info("Download update requested from renderer."); - autoUpdater.downloadUpdate(); + autoUpdater.downloadUpdate().catch((error) => { + log.error("Error downloading update:", error); + }); }); +export { initializeCronTasks }; + logIpcMessages(); diff --git a/src/main/ipc/ipcMainConfig.types.ts b/src/main/ipc/ipcMainConfig.types.ts new file mode 100644 index 0000000..c90c9b4 --- /dev/null +++ b/src/main/ipc/ipcMainConfig.types.ts @@ -0,0 +1,67 @@ +export interface User { + stsTokenManager?: { + accessToken: string; + }; +} + +export interface BodyShop { + shopname: string; + id: string; +} + +export interface GraphQLResponse { + bodyshops_by_pk?: { + imexshopid: string; + shopname: string; + }; + jobs?: Array<{ + labhrs: any; + larhrs: any; + ro_number: string; + ownr_ln: string; + ownr_fn: string; + plate_no: string; + v_vin: string; + v_model_yr: string; + v_make_desc: string; + v_model_desc: string; + vehicle?: { + v_paint_codes?: { + paint_cd1: string; + }; + }; + larhrs_aggregate?: { + aggregate?: { + sum?: { + mod_lb_hrs: number; + }; + }; + }; + ins_co_nm: string; + est_ct_ln: string; + est_ct_fn: string; + job_totals?: { + rates?: { + mapa?: { + total?: { + amount: number; + }; + }; + }; + totals?: { + subtotal?: { + amount: number; + }; + }; + }; + rate_mapa: number; + labhrs_aggregate?: { + aggregate?: { + sum?: { + mod_lb_hrs: number; + }; + }; + }; + rate_lab: number; + }>; +} \ No newline at end of file diff --git a/src/main/ipc/ipcMainHandler.settings.ts b/src/main/ipc/ipcMainHandler.settings.ts index a066d79..b71b636 100644 --- a/src/main/ipc/ipcMainHandler.settings.ts +++ b/src/main/ipc/ipcMainHandler.settings.ts @@ -1,9 +1,27 @@ -import {dialog, IpcMainInvokeEvent} from "electron"; +// main/ipcMainHandler.settings.ts +import { dialog, IpcMainInvokeEvent } from "electron"; import log from "electron-log/main"; import _ from "lodash"; import Store from "../store/store"; -import {getMainWindow} from "../util/toRenderer"; -import {addWatcherPath, removeWatcherPath, StartWatcher, StopWatcher,} from "../watcher/watcher"; +import { getMainWindow } from "../util/toRenderer"; +import { + addWatcherPath, + removeWatcherPath, + StartWatcher, + StopWatcher, +} from "../watcher/watcher"; +import { PaintScaleConfig } from "../../util/types/paintScale"; + + +// Initialize paint scale input configs in store if not set +if (!Store.get("settings.paintScaleInputConfigs")) { + Store.set("settings.paintScaleInputConfigs", []); +} + +// Initialize paint scale output configs in store if not set +if (!Store.get("settings.paintScaleOutputConfigs")) { + Store.set("settings.paintScaleOutputConfigs", []); +} const SettingsWatchedFilePathsAdd = async (): Promise => { const mainWindow = getMainWindow(); @@ -17,21 +35,22 @@ const SettingsWatchedFilePathsAdd = async (): Promise => { if (!result.canceled) { Store.set( - "settings.filepaths", - _.union(result.filePaths, Store.get("settings.filepaths")), + "settings.filepaths", + _.union(result.filePaths, Store.get("settings.filepaths")), ); addWatcherPath(result.filePaths); } return Store.get("settings.filepaths"); }; + const SettingsWatchedFilePathsRemove = async ( - _event: IpcMainInvokeEvent, - path: string, + _event: IpcMainInvokeEvent, + path: string, ): Promise => { Store.set( - "settings.filepaths", - _.without(Store.get("settings.filepaths"), path), + "settings.filepaths", + _.without(Store.get("settings.filepaths"), path), ); removeWatcherPath(path); return Store.get("settings.filepaths"); @@ -46,15 +65,16 @@ const SettingsWatcherPollingGet = async (): Promise<{ interval: number; }> => { const pollingEnabled: { enabled: boolean; interval: number } = - Store.get("settings.polling"); + Store.get("settings.polling"); return { enabled: pollingEnabled.enabled, interval: pollingEnabled.interval }; }; + const SettingsWatcherPollingSet = async ( - _event: IpcMainInvokeEvent, - pollingSettings: { - enabled: boolean; - interval: number; - }, + _event: IpcMainInvokeEvent, + pollingSettings: { + enabled: boolean; + interval: number; + }, ): Promise<{ enabled: boolean; interval: number; @@ -63,15 +83,16 @@ const SettingsWatcherPollingSet = async ( const { enabled, interval } = pollingSettings; Store.set("settings.polling", { enabled, interval }); - //Restart the watcher with these new settings. await StopWatcher(); await StartWatcher(); return { enabled, interval }; }; + const SettingsPpcFilePathGet = async (): Promise => { return Store.get("settings.ppcFilePath"); }; + const SettingsPpcFilePathSet = async (): Promise => { const mainWindow = getMainWindow(); if (!mainWindow) { @@ -83,14 +104,16 @@ const SettingsPpcFilePathSet = async (): Promise => { }); if (!result.canceled) { - Store.set("settings.ppcFilePath", result.filePaths[0]); //There should only ever be on directory that was selected. + Store.set("settings.ppcFilePath", result.filePaths[0]); } return (Store.get("settings.ppcFilePath") as string) || ""; }; + const SettingEmsOutFilePathGet = async (): Promise => { return Store.get("settings.emsOutFilePath"); }; + const SettingEmsOutFilePathSet = async (): Promise => { const mainWindow = getMainWindow(); if (!mainWindow) { @@ -102,12 +125,116 @@ const SettingEmsOutFilePathSet = async (): Promise => { }); if (!result.canceled) { - Store.set("settings.emsOutFilePath", result.filePaths[0]); //There should only ever be on directory that was selected. + Store.set("settings.emsOutFilePath", result.filePaths[0]); } return (Store.get("settings.emsOutFilePath") as string) || ""; }; +const SettingsPaintScaleInputConfigsGet = async ( + _event?: IpcMainInvokeEvent, +): Promise => { + try { + const configs = Store.get("settings.paintScaleInputConfigs") as PaintScaleConfig[]; + log.debug("Retrieved paint scale input configs:", configs); + return configs || []; + } catch (error) { + log.error("Error getting paint scale input configs:", error); + throw error; + } +}; + +const SettingsPaintScaleInputConfigsSet = async ( + _event: IpcMainInvokeEvent, + configs: PaintScaleConfig[], +): Promise => { + try { + Store.set("settings.paintScaleInputConfigs", configs); + log.debug("Saved paint scale input configs:", configs); + return true; + } catch (error) { + log.error("Error setting paint scale input configs:", error); + throw error; + } +}; + +const SettingsPaintScaleInputPathSet = async ( + _event: IpcMainInvokeEvent, +): Promise => { + try { + const mainWindow = getMainWindow(); + if (!mainWindow) { + log.error("No main window found when trying to open dialog"); + return null; + } + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openDirectory"], + }); + if (result.canceled) { + log.debug("Paint scale input path selection canceled"); + return null; + } + const path = result.filePaths[0]; + log.debug("Selected paint scale input path:", path); + return path; + } catch (error) { + log.error("Error setting paint scale input path:", error); + throw error; + } +}; + +const SettingsPaintScaleOutputConfigsGet = async ( + _event?: IpcMainInvokeEvent, +): Promise => { + try { + const configs = Store.get("settings.paintScaleOutputConfigs") as PaintScaleConfig[]; + log.debug("Retrieved paint scale output configs:", configs); + return configs || []; + } catch (error) { + log.error("Error getting paint scale output configs:", error); + throw error; + } +}; + +const SettingsPaintScaleOutputConfigsSet = async ( + _event: IpcMainInvokeEvent, + configs: PaintScaleConfig[], +): Promise => { + try { + Store.set("settings.paintScaleOutputConfigs", configs); + log.debug("Saved paint scale output configs:", configs); + return true; + } catch (error) { + log.error("Error setting paint scale output configs:", error); + throw error; + } +}; + +const SettingsPaintScaleOutputPathSet = async ( + _event: IpcMainInvokeEvent, +): Promise => { + try { + const mainWindow = getMainWindow(); + if (!mainWindow) { + log.error("No main window found when trying to open dialog"); + return null; + } + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openDirectory"], + }); + if (result.canceled) { + log.debug("Paint scale output path selection canceled"); + return null; + } + const path = result.filePaths[0]; + log.debug("Selected paint scale output path:", path); + return path; + } catch (error) { + log.error("Error setting paint scale output path:", error); + throw error; + } +}; + export { SettingsPpcFilePathGet, SettingsPpcFilePathSet, @@ -118,4 +245,10 @@ export { SettingsWatcherPollingSet, SettingEmsOutFilePathGet, SettingEmsOutFilePathSet, -}; + SettingsPaintScaleInputConfigsGet, + SettingsPaintScaleInputConfigsSet, + SettingsPaintScaleInputPathSet, + SettingsPaintScaleOutputConfigsGet, + SettingsPaintScaleOutputConfigsSet, + SettingsPaintScaleOutputPathSet, +}; \ No newline at end of file diff --git a/src/main/ipc/paintScaleHandlers/PPG.ts b/src/main/ipc/paintScaleHandlers/PPG.ts new file mode 100644 index 0000000..da66331 --- /dev/null +++ b/src/main/ipc/paintScaleHandlers/PPG.ts @@ -0,0 +1,261 @@ +import log from "electron-log/main"; +import path from "path"; +import fs from "fs/promises"; +import axios from "axios"; +import { create } from "xmlbuilder2"; +import { parseStringPromise } from "xml2js"; +import store from "../../store/store"; +import client from "../../graphql/graphql-client"; +import { PaintScaleConfig } from "../../../util/types/paintScale"; + +// PPG Input Handler +export async function ppgInputHandler(config: PaintScaleConfig): Promise { + try { + log.info( + `Polling input directory for PPG config ${config.id}: ${config.path}`, + ); + + // Ensure archive and error directories exist + const archiveDir = path.join(config.path!, "archive"); + const errorDir = path.join(config.path!, "error"); + await fs.mkdir(archiveDir, { recursive: true }); + await fs.mkdir(errorDir, { recursive: true }); + + // Check for files + const files = await fs.readdir(config.path!); + for (const file of files) { + // Only process XML files + if (!file.toLowerCase().endsWith(".xml")) { + continue; + } + + const filePath = path.join(config.path!, file); + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + continue; + } + + log.debug(`Processing input file: ${filePath}`); + + // Check file accessibility (e.g., not locked) + try { + await fs.access(filePath, fs.constants.R_OK); + } catch (error) { + log.warn(`File ${filePath} is inaccessible, skipping:`, error); + continue; + } + + // Validate XML structure + let xmlContent : BlobPart; + + try { + xmlContent = await fs.readFile(filePath, "utf8"); + await parseStringPromise(xmlContent); + } catch (error) { + log.error(`Invalid XML in ${filePath}:`, error); + const timestamp = Date.now().toString(); // similar to DateTime.Now.Ticks in C# + const errorPath = path.join(errorDir, `${timestamp}.xml`); + await fs.rename(filePath, errorPath); + log.debug(`Moved invalid file to error: ${errorPath}`); + continue; + } + + // Get authentication token + const token = (store.get("user") as any)?.stsTokenManager?.accessToken; + if (!token) { + log.error(`No authentication token for file: ${filePath}`); + continue; + } + + // Upload file to API + const formData = new FormData(); + formData.append("file", new Blob([xmlContent]), path.basename(filePath)); + formData.append( + "shopId", + (store.get("app.bodyshop") as any)?.shopname || "", + ); + + const baseURL = store.get("app.isTest") + ? import.meta.env.VITE_API_TEST_URL + : import.meta.env.VITE_API_URL; + const finalUrl = `${baseURL}/mixdata/upload`; + + log.debug(`Uploading file to ${finalUrl}`); + + try { + const response = await axios.post(finalUrl, formData, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "multipart/form-data", + }, + }); + + if (response.status === 200) { + log.info(`Successful upload of ${filePath}`); + // Move file to archive + const timestamp = Date.now().toString(); // generate new timestamp + const archivePath = path.join(archiveDir, `${timestamp}.xml`); + await fs.rename(filePath, archivePath); + log.debug(`Moved file to archive: ${archivePath}`); + } else { + log.error( + `Failed to upload ${filePath}: ${response.status} ${response.statusText}`, + response.data, + ); + } + } catch (error) { + log.error(`Error uploading ${filePath}:`, error); + } + } + } catch (error) { + log.error(`Error polling input directory ${config.path}:`, error); + } +} + +// PPG Output Handler +export async function ppgOutputHandler( + config: PaintScaleConfig, +): Promise { + try { + log.info(`Generating PPG output for config ${config.id}: ${config.path}`); + + await fs.mkdir(config.path!, { recursive: true }); + + const query = ` + query PpgData($today: timestamptz!, $todayplus5: timestamptz!, $shopid: uuid!) { + bodyshops_by_pk(id:$shopid) { + id + shopname + imexshopid + } + jobs(where: { + _or: [ + { + _and: [ + { scheduled_in: { _lte: $todayplus5 } }, + { scheduled_in: { _gte: $today } } + ] + }, + { inproduction: { _eq: true } } + ] + }) { + id + ro_number + status + ownr_fn + ownr_ln + ownr_co_nm + v_vin + v_model_yr + v_make_desc + v_model_desc + v_color + plate_no + ins_co_nm + est_ct_fn + est_ct_ln + rate_mapa + rate_lab + job_totals + vehicle { + v_paint_codes + } + labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) { + aggregate { + sum { + mod_lb_hrs + } + } + } + larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) { + aggregate { + sum { + mod_lb_hrs + } + } + } + } + } +`; + + const variables = { + today: new Date().toISOString(), + todayplus5: new Date(Date.now() + 5 * 86400000).toISOString(), + shopid: (store.get("app.bodyshop") as any)?.id, + }; + + const response = (await client.request(query, variables)) as any; + const jobs = response.jobs ?? []; + + const header = { + PPG: { + Header: { + Protocol: { + Message: "PaintShopInterface", + Name: "PPG", + Version: "1.5.0", + }, + Transaction: { + TransactionID: "", + TransactionDate: (() => { + const now = new Date(); + const year = now.getFullYear(); + const month = ("0" + (now.getMonth() + 1)).slice(-2); + const day = ("0" + now.getDate()).slice(-2); + const hours = ("0" + now.getHours()).slice(-2); + const minutes = ("0" + now.getMinutes()).slice(-2); + return `${year}-${month}-${day}:${hours}:${minutes}`; + })(), + }, + Product: { + Name: "ImEX Online", + Version: "", + }, + }, + DataInterface: { + ROData: { + ShopInfo: { + ShopID: response.bodyshops_by_pk?.imexshopid || "", + ShopName: response.bodyshops_by_pk?.shopname || "", + }, + RepairOrders: { + ROCount: jobs.length.toString(), + RO: jobs.map((job: any) => ({ + RONumber: job.ro_number || "", + ROStatus: "Open", + Customer: `${job.ownr_ln || ""}, ${job.ownr_fn || ""}`, + ROPainterNotes: "", + LicensePlateNum: job.plate_no || "", + VIN: job.v_vin || "", + ModelYear: job.v_model_yr || "", + MakeDesc: job.v_make_desc || "", + ModelName: job.v_model_desc || "", + OEMColorCode: job.vehicle?.v_paint_codes?.paint_cd1 || "", + RefinishLaborHours: job.larhrs?.aggregate?.sum?.mod_lb_hrs || 0, + InsuranceCompanyName: job.ins_co_nm || "", + EstimatorName: `${job.est_ct_ln || ""}, ${job.est_ct_fn || ""}`, + PaintMaterialsRevenue: ( + (job.job_totals?.rates?.mapa?.total?.amount || 0) / 100 + ).toFixed(2), + PaintMaterialsRate: job.rate_mapa || 0, + BodyHours: job.labhrs?.aggregate?.sum?.mod_lb_hrs || 0, + BodyLaborRate: job.rate_lab || 0, + TotalCostOfRepairs: ( + (job.job_totals?.totals?.subtotal?.amount || 0) / 100 + ).toFixed(2), + })), + }, + }, + }, + }, + }; + + const xml = create({ version: "1.0" }, header).end({ prettyPrint: true }); + const outputPath = path.join(config.path!, `PPGPaint.xml`); + await fs.writeFile(outputPath, xml); + log.info(`Saved PPG output XML to ${outputPath}`); + } catch (error) { + log.error(`Error generating PPG output for config ${config.id}:`, 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..60ae2b4 --- /dev/null +++ b/src/main/setup-keep-alive-agent.ts @@ -0,0 +1,71 @@ +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); + +// Define the interval as a variable (in seconds) +const KEEP_ALIVE_INTERVAL_SECONDS = 15 * 60; // 15 minutes + +export async function setupKeepAliveAgent(): Promise { + const plistContent = ` + + + + Label + com.convenientbrands.bodyshop-desktop.keepalive + ProgramArguments + + open + imexmedia://keep-alive + + RunAtLoad + + StartInterval + ${KEEP_ALIVE_INTERVAL_SECONDS} + +`; + + 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..a0af962 --- /dev/null +++ b/src/main/setup-keep-alive-task.ts @@ -0,0 +1,53 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import log from "electron-log/main"; + +const execPromise = promisify(exec); + +// Define the interval as a variable (in minutes) +const KEEP_ALIVE_INTERVAL_MINUTES = 15; + +export async function setupKeepAliveTask(): Promise { + const taskName = "ImEXShopPartnerKeepAlive"; + const protocolUrl = "imexmedia://keep-alive"; + // Use rundll32.exe to silently open the URL as a protocol + const command = `rundll32.exe url.dll,OpenURL "${protocolUrl}"`; + // Escape quotes for schtasks /tr parameter + const escapedCommand = command.replace(/"/g, '\\"'); + + const schtasksCommand = `schtasks /create /tn "${taskName}" /tr "${escapedCommand}" /sc minute /mo ${KEEP_ALIVE_INTERVAL_MINUTES} /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 +} diff --git a/src/renderer/src/components/Settings/PaintScale/usePaintScaleConfig.ts b/src/renderer/src/components/Settings/PaintScale/usePaintScaleConfig.ts new file mode 100644 index 0000000..f87be8f --- /dev/null +++ b/src/renderer/src/components/Settings/PaintScale/usePaintScaleConfig.ts @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react'; +import ipcTypes from '../../../../../util/ipcTypes.json'; +import { PaintScaleConfig, PaintScaleType } from '../../../../../util/types/paintScale'; +import { message } from "antd"; +import {useTranslation} from "react-i18next"; + +type ConfigType = 'input' | 'output'; + +export const usePaintScaleConfig = (configType: ConfigType) => { + const [paintScaleConfigs, setPaintScaleConfigs] = useState([]); + const { t } = useTranslation(); + + // Get the appropriate IPC methods based on config type + const getConfigsMethod = configType === 'input' + ? ipcTypes.toMain.settings.paintScale.getInputConfigs + : ipcTypes.toMain.settings.paintScale.getOutputConfigs; + + const setConfigsMethod = configType === 'input' + ? ipcTypes.toMain.settings.paintScale.setInputConfigs + : ipcTypes.toMain.settings.paintScale.setOutputConfigs; + + const setPathMethod = configType === 'input' + ? ipcTypes.toMain.settings.paintScale.setInputPath + : ipcTypes.toMain.settings.paintScale.setOutputPath; + + // Load paint scale configs on mount + useEffect(() => { + window.electron.ipcRenderer + .invoke(getConfigsMethod) + .then((configs: PaintScaleConfig[]) => { + // Ensure all configs have a pollingInterval and type (for backward compatibility) + const updatedConfigs = configs.map(config => ({ + ...config, + pollingInterval: config.pollingInterval || 1440, // Default to 1440 seconds + type: config.type || PaintScaleType.PPG, // Default type if missing + })); + setPaintScaleConfigs(updatedConfigs || []); + }) + .catch((error) => { + console.error(`Failed to load paint scale ${configType} configs:`, error); + }); + }, [getConfigsMethod]); + + // Save configs to store and notify main process of config changes + const saveConfigs = (configs: PaintScaleConfig[]) => { + window.electron.ipcRenderer + .invoke(setConfigsMethod, configs) + .then(() => { + // Notify main process to update cron job + if (configType === 'input') { + window.electron.ipcRenderer.send(ipcTypes.toMain.settings.paintScale.updateInputCron, configs); + } else if (configType === 'output') { + window.electron.ipcRenderer.send(ipcTypes.toMain.settings.paintScale.updateOutputCron, configs); + } + }) + .catch((error) => { + console.error(`Failed to save paint scale ${configType} configs:`, error); + }); + }; + + // New helper to check if a path is unique across input and output configs + const checkPathUnique = async (newPath: string): Promise => { + try { + const inputConfigs: PaintScaleConfig[] = await window.electron.ipcRenderer.invoke(ipcTypes.toMain.settings.paintScale.getInputConfigs); + const outputConfigs: PaintScaleConfig[] = await window.electron.ipcRenderer.invoke(ipcTypes.toMain.settings.paintScale.getOutputConfigs); + const allConfigs = [...inputConfigs, ...outputConfigs]; + // Allow updating the current config even if its current value equals newPath. + return !allConfigs.some(config => config.path === newPath); + } catch (error) { + console.error("Failed to check unique path:", error); + return false; + } + }; + + // Handle adding a new paint scale config + const handleAddConfig = (type: PaintScaleType) => { + const newConfig: PaintScaleConfig = { + id: Date.now().toString(), + type, + pollingInterval: 1440, // Default to 1440 seconds + }; + const updatedConfigs = [...paintScaleConfigs, newConfig]; + setPaintScaleConfigs(updatedConfigs); + saveConfigs(updatedConfigs); + }; + + // Handle removing a config + const handleRemoveConfig = (id: string) => { + const updatedConfigs = paintScaleConfigs.filter((config) => config.id !== id); + setPaintScaleConfigs(updatedConfigs); + saveConfigs(updatedConfigs); + }; + + // Handle path selection (modified to check directory uniqueness) + const handlePathChange = async (id: string) => { + try { + const path: string | null = await window.electron.ipcRenderer.invoke(setPathMethod, id); + if (path) { + const isUnique = await checkPathUnique(path); + if (!isUnique) { + message.error(t("settings.errors.duplicatePath")); + return; + } + const updatedConfigs = paintScaleConfigs.map((config) => + config.id === id ? { ...config, path } : config, + ); + setPaintScaleConfigs(updatedConfigs); + saveConfigs(updatedConfigs); + } + } catch (error) { + console.error(`Failed to set paint scale ${configType} path:`, error); + } + }; + + // Handle polling interval change + const handlePollingIntervalChange = (id: string, pollingInterval: number) => { + const updatedConfigs = paintScaleConfigs.map((config) => + config.id === id ? { ...config, pollingInterval } : config, + ); + setPaintScaleConfigs(updatedConfigs); + saveConfigs(updatedConfigs); + }; + + return { + paintScaleConfigs, + handleAddConfig, + handleRemoveConfig, + handlePathChange, + handlePollingIntervalChange, + }; +}; diff --git a/src/renderer/src/components/Settings/Settings.PaintScaleInputPaths.tsx b/src/renderer/src/components/Settings/Settings.PaintScaleInputPaths.tsx new file mode 100644 index 0000000..444047b --- /dev/null +++ b/src/renderer/src/components/Settings/Settings.PaintScaleInputPaths.tsx @@ -0,0 +1,180 @@ +import { + CheckCircleFilled, + FileAddFilled, + FolderOpenFilled, + WarningFilled, +} from "@ant-design/icons"; +import { + Button, + Card, + Input, + Modal, + Select, + Space, + Table, + Tag, + Tooltip, +} from "antd"; +import { FC, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + PaintScaleConfig, + PaintScaleType, + paintScaleTypeOptions, +} from "../../../../util/types/paintScale"; +import { usePaintScaleConfig } from "./PaintScale/usePaintScaleConfig"; + +const SettingsPaintScaleInputPaths: FC = () => { + const { t } = useTranslation(); + const { + paintScaleConfigs, + handleAddConfig, + handleRemoveConfig, + handlePathChange, + handlePollingIntervalChange, + } = usePaintScaleConfig("input"); + + const [isModalVisible, setIsModalVisible] = useState(false); + const [selectedType, setSelectedType] = useState(null); + + // Show modal when adding a new path + const showAddPathModal = () => { + setSelectedType(null); + setIsModalVisible(true); + }; + + // Handle modal confirmation + const handleModalOk = () => { + if (selectedType) { + handleAddConfig(selectedType); + setIsModalVisible(false); + } + }; + + // Handle modal cancellation + const handleModalCancel = () => { + setIsModalVisible(false); + }; + + // Table columns for paint scale configs + const columns = [ + { + title: t("settings.labels.paintScaleType"), + dataIndex: "type", + key: "type", + render: (type: PaintScaleType) => { + const typeOption = paintScaleTypeOptions.find( + (option) => option.value === type, + ); + const label = typeOption ? typeOption.label : type; + const colorMap: Partial> = { + [PaintScaleType.PPG]: "blue", + // Add other types and colors as needed + }; + return {label}; + }, + }, + { + title: t("settings.labels.paintScalePath"), + dataIndex: "path", + key: "path", + render: (path: string | null, record: PaintScaleConfig) => { + const isValid = path && path.trim() !== ""; + return ( + + + {isValid ? ( + + ) : ( + + )} + + } + /> + + ), + }, + ]; + + return ( + <> + }> + {t("settings.actions.addpath")} + + } + > + + + + + + ) : ( + + ) + } + /> + + ), + }, + ]; + + return ( + <> + }> + {t("settings.actions.addpath")} + + } + > +
+ + + +