From 4edd6cec09158efb4fc3204f2d283750138ca163 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Mon, 2 Mar 2026 10:17:51 -0800 Subject: [PATCH] Tray improvements. --- package-lock.json | 25 +++++- package.json | 2 + src/main/decoder/decoder.ts | 12 +-- src/main/index.ts | 23 ++++- src/main/util/trayStatus.ts | 88 ++++++++++++++++++ src/main/watcher/watcher.ts | 6 +- src/renderer/src/components/Home/Home.tsx | 62 ++++++++----- src/util/newWindow.ts | 103 +++++++++++++++++++++- 8 files changed, 277 insertions(+), 44 deletions(-) create mode 100644 src/main/util/trayStatus.ts diff --git a/package-lock.json b/package-lock.json index cab5b27..396df1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "esdp", - "version": "0.0.1", + "version": "0.0.1-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "esdp", - "version": "0.0.1", + "version": "0.0.1-alpha.1", "hasInstallScript": true, "dependencies": { "@apollo/client": "^4.1.6", @@ -19,6 +19,7 @@ "dayjs": "^1.11.19", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", + "pngjs": "^7.0.0", "source-map-support": "^0.5.21" }, "devDependencies": { @@ -32,6 +33,7 @@ "@types/lodash": "^4.17.24", "@types/node": "^25.3.0", "@types/node-cron": "^3.0.11", + "@types/pngjs": "^6.0.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/source-map-support": "^0.5.10", @@ -5269,6 +5271,16 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -12221,6 +12233,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index aca68e8..6f4323b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dayjs": "^1.11.19", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", + "pngjs": "^7.0.0", "source-map-support": "^0.5.21" }, "devDependencies": { @@ -49,6 +50,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/source-map-support": "^0.5.10", + "@types/pngjs": "^6.0.5", "@types/xml2js": "^0.4.14", "@vitejs/plugin-react": "^5.1.4", "antd": "^6.3.1", diff --git a/src/main/decoder/decoder.ts b/src/main/decoder/decoder.ts index 7c32dc7..0628de3 100644 --- a/src/main/decoder/decoder.ts +++ b/src/main/decoder/decoder.ts @@ -151,19 +151,9 @@ async function ImportJob(filepath: string): Promise { }); setAppProgressbar(-1); - const items = ["One", "Two", "Three"]; - const n = new Notification({ - title: "Choose an Action!", - actions: [ - { type: "button", text: "Action 1" }, - { type: "button", text: "Action 2" }, - { type: "selection", text: "Apply", items }, - ], - }); - const uploadNotification = new Notification({ title: "Job Scrubbed", - body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}. Click to view.`, + body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}.`, actions: [ { text: "View in App", type: "button" as const }, ...(scrubPdfURL ? [{ text: "View PDF", type: "button" as const }] : []), diff --git a/src/main/index.ts b/src/main/index.ts index 1610920..57898a9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,7 +28,16 @@ import store from "./store/store"; import { checkForAppUpdates } from "./util/checkForAppUpdates"; import ensureWindowOnScreen from "./util/ensureWindowOnScreen"; import { getMainWindow } from "./util/toRenderer"; -import { GetAllEnvFiles, GetLatestEnvFile } from "./watcher/watcher"; +import { + setTrayBaseImage, + setTrayInstance, + setWatcherTrayStatus, +} from "./util/trayStatus"; +import { + GetAllEnvFiles, + GetLatestEnvFile, + StartWatcher, +} from "./watcher/watcher"; const appIconToUse = imexAppIcon; @@ -497,8 +506,15 @@ app.whenReady().then(async () => { } //Create Tray - const trayicon = nativeImage.createFromPath(appIconToUse); - const tray = new Tray(trayicon.resize({ width: 16 })); + const trayBase = nativeImage + .createFromPath(appIconToUse) + .resize({ width: 16, height: 16 }); + const tray = new Tray(trayBase); + + setTrayInstance(tray); + setTrayBaseImage(trayBase); + setWatcherTrayStatus(false); + const contextMenu = Menu.buildFromTemplate([ { label: "Show App", @@ -568,6 +584,7 @@ app.whenReady().then(async () => { isKeepAliveLaunch = true; } + StartWatcher(); //The update itself will run when the bodyshop record is queried to know what release channel to use. openMainWindow(); diff --git a/src/main/util/trayStatus.ts b/src/main/util/trayStatus.ts new file mode 100644 index 0000000..eacd96a --- /dev/null +++ b/src/main/util/trayStatus.ts @@ -0,0 +1,88 @@ +import { nativeImage, type NativeImage, type Tray } from "electron"; + +let trayInstance: Tray | undefined; +let trayBaseImage: NativeImage | undefined; +let watcherStarted = false; + +export function setTrayInstance(tray: Tray): void { + trayInstance = tray; + applyTrayImage(); +} + +export function setTrayBaseImage(image: NativeImage): void { + trayBaseImage = image; + applyTrayImage(); +} + +export function setWatcherTrayStatus(started: boolean): void { + watcherStarted = started; + applyTrayImage(); +} + +function applyTrayImage(): void { + if (!trayInstance || !trayBaseImage) return; + trayInstance.setImage(buildStatusTrayImage(trayBaseImage, watcherStarted)); +} + +function buildStatusTrayImage( + base: NativeImage, + started: boolean, +): NativeImage { + try { + const pngjs = (() => { + try { + return require("pngjs") as { + PNG?: { sync?: { read?: (b: Buffer) => unknown; write?: (p: unknown) => Buffer } }; + }; + } catch { + return null; + } + })(); + + if (!pngjs?.PNG?.sync?.read || !pngjs.PNG.sync.write) return base; + + const basePng = base.toPNG(); + const png = pngjs.PNG.sync.read(basePng) as { + width: number; + height: number; + data: Uint8Array; + }; + + const width = png.width; + const height = png.height; + const radius = Math.max(2, Math.round(Math.min(width, height) * 0.2)); + const centerX = width - radius - 1; + const centerY = height - radius - 1; + + const fill = started + ? { r: 0x52, g: 0xc4, b: 0x1a, a: 0xff } + : { r: 0xff, g: 0x4d, b: 0x4f, a: 0xff }; + const stroke = { r: 0xff, g: 0xff, b: 0xff, a: 0xff }; + + for (let y = centerY - radius - 1; y <= centerY + radius + 1; y++) { + for (let x = centerX - radius - 1; x <= centerX + radius + 1; x++) { + if (x < 0 || y < 0 || x >= width || y >= height) continue; + const dx = x - centerX; + const dy = y - centerY; + const d2 = dx * dx + dy * dy; + const r2 = radius * radius; + const inner2 = (radius - 1) * (radius - 1); + + let color: typeof fill | null = null; + if (d2 <= r2 && d2 >= inner2) color = stroke; + else if (d2 < inner2) color = fill; + if (!color) continue; + + const idx = (png.width * y + x) << 2; + png.data[idx] = color.r; + png.data[idx + 1] = color.g; + png.data[idx + 2] = color.b; + png.data[idx + 3] = color.a; + } + } + + return nativeImage.createFromBuffer(pngjs.PNG.sync.write(png)); + } catch { + return base; + } +} diff --git a/src/main/watcher/watcher.ts b/src/main/watcher/watcher.ts index cad711c..71e1057 100644 --- a/src/main/watcher/watcher.ts +++ b/src/main/watcher/watcher.ts @@ -1,5 +1,5 @@ import chokidar, { FSWatcher } from "chokidar"; -import { BrowserWindow, Notification } from "electron"; +import { Notification } from "electron"; import log from "electron-log/main"; import fs from "fs"; import path from "path"; @@ -8,6 +8,7 @@ import ipcTypes from "../../util/ipcTypes.json"; import ImportJob from "../decoder/decoder"; import store from "../store/store"; import getMainWindow from "../../util/getMainWindow"; +import { setWatcherTrayStatus } from "../util/trayStatus"; let watcher: FSWatcher | null; async function StartWatcher(): Promise { @@ -78,6 +79,7 @@ async function StartWatcher(): Promise { // }) .on("error", function (error) { log.error("Error in Watcher", errorTypeCheck(error)); + setWatcherTrayStatus(false); // mainWindow.webContents.send( // ipcTypes.toRenderer.watcher.error, // errorTypeCheck(error) @@ -114,6 +116,7 @@ function onWatcherReady(): void { body: "Newly exported estimates will be automatically uploaded.", }).show(); log.info("Confirmed watched paths:", watcher.getWatched()); + setWatcherTrayStatus(true); mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.started); } } @@ -124,6 +127,7 @@ async function StopWatcher(): Promise { if (watcher) { await watcher.close(); log.info("Watcher stopped."); + setWatcherTrayStatus(false); mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped); new Notification({ diff --git a/src/renderer/src/components/Home/Home.tsx b/src/renderer/src/components/Home/Home.tsx index 0dadac4..6379c03 100644 --- a/src/renderer/src/components/Home/Home.tsx +++ b/src/renderer/src/components/Home/Home.tsx @@ -10,6 +10,7 @@ import { } from "@ant-design/icons"; import { Button, + Badge, Card, Col, Flex, @@ -25,7 +26,10 @@ import { import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; +import { selectWatcherStatus } from "@renderer/redux/app.slice"; +import { useAppSelector } from "@renderer/redux/reduxHooks"; import ipcTypes from "../../../../util/ipcTypes.json"; +import dayjs from "dayjs"; const { Title, Text } = Typography; @@ -80,6 +84,8 @@ const Home: FC = () => { const { token } = theme.useToken(); const ipcRenderer = window.electron.ipcRenderer; + const isWatcherStarted = useAppSelector(selectWatcherStatus); + const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [totalJobs, setTotalJobs] = useState(0); @@ -231,20 +237,20 @@ const Home: FC = () => { {record.results?.length ?? 0} ), }, - { - title: "Status", - key: "status", - width: 120, - render: () => ( - } - style={{ margin: 0 }} - > - Done - - ), - }, + // { + // title: "Status", + // key: "status", + // width: 120, + // render: () => ( + // } + // style={{ margin: 0 }} + // > + // Done + // + // ), + // }, { title: "Actions", key: "actions", @@ -309,14 +315,24 @@ const Home: FC = () => { {t("dashboard.labels.dashboard")} - + + + + {/* Stats Cards */} @@ -374,7 +390,7 @@ const Home: FC = () => { } value={ lastProcessed - ? new Date(lastProcessed).toLocaleTimeString() + ? dayjs(lastProcessed).format("MM/DD/YYYY @ hh:mm a") : "—" } prefix={} diff --git a/src/util/newWindow.ts b/src/util/newWindow.ts index f823f83..1fe3c88 100644 --- a/src/util/newWindow.ts +++ b/src/util/newWindow.ts @@ -1,4 +1,45 @@ -import { BrowserWindow, shell } from "electron"; +import { BrowserView, BrowserWindow, shell } from "electron"; + +const loadingHtml = ` + + + + + Loading external PDF… + + + +
+ +
Loading external PDF…
+
+ +`; + +function toDataUrl(html: string): string { + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} async function newWindow(url: string): Promise { // BrowserWindow is a main-process API. If this gets called from preload/renderer @@ -9,13 +50,67 @@ async function newWindow(url: string): Promise { } const pdfWindow = new BrowserWindow({ + title: "Loading…", + show: true, + }); + + const view = new BrowserView({ webPreferences: { - plugins: true, // Enable PDF viewing + plugins: true, }, }); - await pdfWindow.loadURL(url); - pdfWindow.focus(); + const attachView = (): void => { + const [width, height] = pdfWindow.getContentSize(); + pdfWindow.setBrowserView(view); + view.setBounds({ x: 0, y: 0, width, height }); + view.setAutoResize({ width: true, height: true }); + }; + + attachView(); + + await view.webContents.loadURL(toDataUrl(loadingHtml)); + pdfWindow.setProgressBar(2); + + const showError = async (message: string): Promise => { + pdfWindow.setProgressBar(-1); + await view.webContents.loadURL( + toDataUrl(` + Failed to load + +

Failed to load

+
${message.replaceAll("<", "<").replaceAll(">", ">")}
+ `), + ); + }; + + const updateTitle = (maybeTitle?: string): void => { + const title = (maybeTitle ?? view.webContents.getTitle() ?? "").trim(); + pdfWindow.setTitle(title || url); + }; + + view.webContents.on("page-title-updated", (_event, title) => { + updateTitle(title); + }); + + view.webContents.on("did-start-loading", () => { + pdfWindow.setTitle("Loading…"); + pdfWindow.setProgressBar(2); + }); + + view.webContents.on("did-stop-loading", () => { + pdfWindow.setProgressBar(-1); + updateTitle(); + pdfWindow.focus(); + }); + + view.webContents.on("did-fail-load", async (_event, _code, description) => { + await showError(description || "Unknown error"); + }); + + view.webContents.loadURL(url).catch(async (err) => { + await showError(err instanceof Error ? err.message : String(err)); + }); } export default newWindow;