Tray improvements.

This commit is contained in:
Patrick Fic
2026-03-02 10:17:51 -08:00
parent 8aa82df455
commit 4edd6cec09
8 changed files with 277 additions and 44 deletions

25
package-lock.json generated
View File

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

View File

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

View File

@@ -151,19 +151,9 @@ async function ImportJob(filepath: string): Promise<void> {
});
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 }] : []),

View File

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

View File

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

View File

@@ -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<boolean> {
@@ -78,6 +79,7 @@ async function StartWatcher(): Promise<boolean> {
// })
.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<boolean> {
if (watcher) {
await watcher.close();
log.info("Watcher stopped.");
setWatcherTrayStatus(false);
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
new Notification({

View File

@@ -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<ScrubHistoryItem[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [totalJobs, setTotalJobs] = useState<number>(0);
@@ -231,20 +237,20 @@ const Home: FC = () => {
<Text strong>{record.results?.length ?? 0}</Text>
),
},
{
title: "Status",
key: "status",
width: 120,
render: () => (
<Tag
color="success"
icon={<CheckCircleOutlined />}
style={{ margin: 0 }}
>
Done
</Tag>
),
},
// {
// title: "Status",
// key: "status",
// width: 120,
// render: () => (
// <Tag
// color="success"
// icon={<CheckCircleOutlined />}
// style={{ margin: 0 }}
// >
// Done
// </Tag>
// ),
// },
{
title: "Actions",
key: "actions",
@@ -309,14 +315,24 @@ const Home: FC = () => {
<Title level={2} style={{ margin: 0 }}>
{t("dashboard.labels.dashboard")}
</Title>
<Button
type="default"
icon={<SettingOutlined />}
onClick={() => navigate("/settings")}
size="large"
>
Settings
</Button>
<Space align="center" size="middle">
<Badge
status={isWatcherStarted ? "success" : "error"}
text={
isWatcherStarted
? t("settings.labels.started")
: t("settings.labels.stopped")
}
/>
<Button
type="default"
icon={<SettingOutlined />}
onClick={() => navigate("/settings")}
size="large"
>
Settings
</Button>
</Space>
</Flex>
{/* 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={<ClockCircleOutlined style={{ color: "#fff" }} />}

View File

@@ -1,4 +1,45 @@
import { BrowserWindow, shell } from "electron";
import { BrowserView, BrowserWindow, shell } from "electron";
const loadingHtml = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Loading external PDF…</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #ffffff;
color: rgba(0,0,0,0.88);
}
.wrap { display: flex; align-items: center; gap: 12px; }
.spinner {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid rgba(0,0,0,0.15);
border-top-color: rgba(0,0,0,0.55);
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.text { font-size: 14px; }
</style>
</head>
<body>
<div class="wrap">
<div class="spinner" aria-hidden="true"></div>
<div class="text">Loading external PDF…</div>
</div>
</body>
</html>`;
function toDataUrl(html: string): string {
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
async function newWindow(url: string): Promise<void> {
// BrowserWindow is a main-process API. If this gets called from preload/renderer
@@ -9,13 +50,67 @@ async function newWindow(url: string): Promise<void> {
}
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<void> => {
pdfWindow.setProgressBar(-1);
await view.webContents.loadURL(
toDataUrl(`<!doctype html><meta charset="utf-8" />
<title>Failed to load</title>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; padding: 16px;">
<h3 style="margin: 0 0 8px 0;">Failed to load</h3>
<div style="color: rgba(0,0,0,0.65);">${message.replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</div>
</body>`),
);
};
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;