diff --git a/.changeset/fn-6512-dashboard-pwa-icons.md b/.changeset/fn-6512-dashboard-pwa-icons.md
new file mode 100644
index 0000000000..61a689ee6a
--- /dev/null
+++ b/.changeset/fn-6512-dashboard-pwa-icons.md
@@ -0,0 +1,5 @@
+---
+"@runfusion/fusion": patch
+---
+
+Refresh dashboard mobile and PWA home-screen icons from the canonical Fusion logo and bump the service-worker cache for installed app updates.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index feb04e561a..ab2ee06873 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -8,6 +8,10 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart.
+## Mobile/PWA app icons
+
+The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.
+
## Browser Navigation
The dashboard now handles browser back navigation consistently on desktop and mobile.
diff --git a/packages/dashboard/app/__tests__/pwa.test.ts b/packages/dashboard/app/__tests__/pwa.test.ts
index 522dc5fa5d..45fe05fac2 100644
--- a/packages/dashboard/app/__tests__/pwa.test.ts
+++ b/packages/dashboard/app/__tests__/pwa.test.ts
@@ -1,8 +1,16 @@
-import { readFileSync } from "node:fs";
+import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
+import { inflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
+type DecodedPng = {
+ width: number;
+ height: number;
+ colorType: number;
+ pixels: Buffer;
+};
+
function getStandaloneDisplayModeBlock(css: string): string {
const match = /@media\s*\(\s*display-mode:\s*standalone\s*\)\s*\{/.exec(css);
expect(match).toBeTruthy();
@@ -21,6 +29,89 @@ function getStandaloneDisplayModeBlock(css: string): string {
return css.slice(start, i);
}
+function decodeRgbaPng(filePath: string): DecodedPng {
+ const buffer = readFileSync(filePath);
+ const signature = buffer.subarray(0, 8).toString("hex");
+ expect(signature).toBe("89504e470d0a1a0a");
+
+ let offset = 8;
+ let width = 0;
+ let height = 0;
+ let bitDepth = 0;
+ let colorType = 0;
+ const idatChunks: Buffer[] = [];
+
+ while (offset < buffer.length) {
+ const length = buffer.readUInt32BE(offset);
+ const type = buffer.subarray(offset + 4, offset + 8).toString("ascii");
+ const dataStart = offset + 8;
+ const dataEnd = dataStart + length;
+ const data = buffer.subarray(dataStart, dataEnd);
+
+ if (type === "IHDR") {
+ width = data.readUInt32BE(0);
+ height = data.readUInt32BE(4);
+ bitDepth = data.readUInt8(8);
+ colorType = data.readUInt8(9);
+ } else if (type === "IDAT") {
+ idatChunks.push(data);
+ } else if (type === "IEND") {
+ break;
+ }
+
+ offset = dataEnd + 4;
+ }
+
+ expect(bitDepth).toBe(8);
+ expect(colorType).toBe(6);
+
+ const bytesPerPixel = 4;
+ const stride = width * bytesPerPixel;
+ const inflated = inflateSync(Buffer.concat(idatChunks));
+ const pixels = Buffer.alloc(width * height * bytesPerPixel);
+ let inputOffset = 0;
+ let outputOffset = 0;
+
+ for (let y = 0; y < height; y += 1) {
+ const filter = inflated[inputOffset];
+ inputOffset += 1;
+
+ for (let x = 0; x < stride; x += 1) {
+ const raw = inflated[inputOffset + x];
+ const left = x >= bytesPerPixel ? pixels[outputOffset + x - bytesPerPixel] : 0;
+ const up = y > 0 ? pixels[outputOffset + x - stride] : 0;
+ const upLeft = y > 0 && x >= bytesPerPixel ? pixels[outputOffset + x - stride - bytesPerPixel] : 0;
+ let value: number;
+
+ if (filter === 0) {
+ value = raw;
+ } else if (filter === 1) {
+ value = raw + left;
+ } else if (filter === 2) {
+ value = raw + up;
+ } else if (filter === 3) {
+ value = raw + Math.floor((left + up) / 2);
+ } else if (filter === 4) {
+ const predictor = left + up - upLeft;
+ const pa = Math.abs(predictor - left);
+ const pb = Math.abs(predictor - up);
+ const pc = Math.abs(predictor - upLeft);
+ const paeth = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft;
+ value = raw + paeth;
+ } else {
+ throw new Error(`Unsupported PNG filter ${filter} in ${filePath}`);
+ }
+
+ pixels[outputOffset + x] = value & 0xff;
+ }
+
+ inputOffset += stride;
+ outputOffset += stride;
+ }
+
+ return { width, height, colorType, pixels };
+}
+
describe("PWA configuration", () => {
it("manifest defines required PWA fields and icon sizes", () => {
const manifestPath = resolve(__dirname, "../public/manifest.json");
@@ -29,7 +120,7 @@ describe("PWA configuration", () => {
short_name?: string;
start_url?: string;
display?: string;
- icons?: Array<{ sizes?: string }>;
+ icons?: Array<{ src?: string; sizes?: string; type?: string; purpose?: string }>;
};
expect(manifest.name).toBe("Fusion");
@@ -37,8 +128,18 @@ describe("PWA configuration", () => {
expect(manifest.start_url).toBe("/");
expect(manifest.display).toBe("standalone");
expect(Array.isArray(manifest.icons)).toBe(true);
- expect(manifest.icons?.some((icon) => icon.sizes?.includes("192"))).toBe(true);
- expect(manifest.icons?.some((icon) => icon.sizes?.includes("512"))).toBe(true);
+ expect(manifest.icons).toContainEqual({
+ src: "/icons/icon-192.png",
+ sizes: "192x192",
+ type: "image/png",
+ purpose: "any",
+ });
+ expect(manifest.icons).toContainEqual({
+ src: "/icons/icon-512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "any",
+ });
});
it("index.html includes required PWA meta tags", () => {
@@ -93,7 +194,7 @@ describe("PWA configuration", () => {
expect(swSource).toContain('addEventListener("install"');
expect(swSource).toContain('addEventListener("fetch"');
expect(swSource).toContain('addEventListener("activate"');
- expect(swSource).toMatch(/fusion-cache-v\d+/);
+ expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v4";');
});
it("service worker bypasses SSE requests instead of trying to cache them", () => {
@@ -162,21 +263,60 @@ describe("PWA configuration", () => {
expect(logoSvg).not.toContain("r=\"20\"");
});
- it("PWA icon files exist with correct sizes", async () => {
- const fs = await import("node:fs");
+ it("PWA icon files exist, decode to expected sizes, and are opaque non-blank PNGs", () => {
+ const icons = [
+ { path: resolve(__dirname, "../public/icons/icon-192.png"), size: 192 },
+ { path: resolve(__dirname, "../public/icons/icon-512.png"), size: 512 },
+ ];
- const icon192Path = resolve(__dirname, "../public/icons/icon-192.png");
- const icon512Path = resolve(__dirname, "../public/icons/icon-512.png");
+ for (const icon of icons) {
+ expect(existsSync(icon.path)).toBe(true);
+ expect(statSync(icon.path).size).toBeGreaterThan(icon.size * 12);
- expect(fs.existsSync(icon192Path)).toBe(true);
- expect(fs.existsSync(icon512Path)).toBe(true);
+ const png = decodeRgbaPng(icon.path);
+ expect(png.width).toBe(icon.size);
+ expect(png.height).toBe(icon.size);
+ expect(png.colorType).toBe(6);
- // Verify PNG files have reasonable size (not empty)
- const stats192 = fs.statSync(icon192Path);
- const stats512 = fs.statSync(icon512Path);
+ let opaquePixels = 0;
+ let transparentPixels = 0;
+ let brandMarkPixels = 0;
+ const brandBackground = [0x1a, 0x1a, 0x2e];
- expect(stats192.size).toBeGreaterThan(100);
- expect(stats512.size).toBeGreaterThan(100);
+ for (let index = 0; index < png.pixels.length; index += 4) {
+ const alpha = png.pixels[index + 3];
+ if (alpha === 255) opaquePixels += 1;
+ else transparentPixels += 1;
+
+ const colorDistance =
+ Math.abs(png.pixels[index] - brandBackground[0]) +
+ Math.abs(png.pixels[index + 1] - brandBackground[1]) +
+ Math.abs(png.pixels[index + 2] - brandBackground[2]);
+ if (colorDistance > 8) brandMarkPixels += 1;
+ }
+
+ expect(transparentPixels).toBe(0);
+ expect(opaquePixels).toBe(icon.size * icon.size);
+ expect(brandMarkPixels).toBeGreaterThan(icon.size * icon.size * 0.1);
+ }
+ });
+
+ it("wires the same PWA icons through manifest, apple touch, and service-worker precache", () => {
+ const manifest = JSON.parse(readFileSync(resolve(__dirname, "../public/manifest.json"), "utf8")) as {
+ icons?: Array<{ src?: string; sizes?: string; purpose?: string }>;
+ };
+ const indexHtml = readFileSync(resolve(__dirname, "../index.html"), "utf8");
+ const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
+ const iconSources = ["/icons/icon-192.png", "/icons/icon-512.png"];
+
+ for (const iconSource of iconSources) {
+ expect(manifest.icons?.some((icon) => icon.src === iconSource && icon.purpose === "any")).toBe(true);
+ expect(swSource).toContain(`"${iconSource}"`);
+ }
+
+ expect(indexHtml).toContain('');
+ expect(indexHtml).toContain('');
+ expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v4";');
});
});
});
diff --git a/packages/dashboard/app/public/icons/icon-192.png b/packages/dashboard/app/public/icons/icon-192.png
index 0790c87419..7233652e1e 100644
Binary files a/packages/dashboard/app/public/icons/icon-192.png and b/packages/dashboard/app/public/icons/icon-192.png differ
diff --git a/packages/dashboard/app/public/icons/icon-512.png b/packages/dashboard/app/public/icons/icon-512.png
index 5fa632da52..20a40dce19 100644
Binary files a/packages/dashboard/app/public/icons/icon-512.png and b/packages/dashboard/app/public/icons/icon-512.png differ
diff --git a/packages/dashboard/app/public/sw.js b/packages/dashboard/app/public/sw.js
index dd28a0ff50..9f53c68c68 100644
--- a/packages/dashboard/app/public/sw.js
+++ b/packages/dashboard/app/public/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = "fusion-cache-v3";
+const CACHE_NAME = "fusion-cache-v4";
const APP_SHELL_URLS = [
"/",
"/index.html",
diff --git a/packages/desktop/scripts/generate-icons.ts b/packages/desktop/scripts/generate-icons.ts
index 4e44d9bb27..bad5586aff 100644
--- a/packages/desktop/scripts/generate-icons.ts
+++ b/packages/desktop/scripts/generate-icons.ts
@@ -9,20 +9,52 @@ const repoRoot = resolve(packageDir, "../..");
const sourceSvgPath = resolve(repoRoot, "packages/dashboard/app/public/logo.svg");
const outputDir = resolve(packageDir, "src/icons");
+const dashboardPwaIconDir = resolve(repoRoot, "packages/dashboard/app/public/icons");
const iconSizes = [16, 32, 48] as const;
+const dashboardPwaIconSizes = [192, 512] as const;
const APP_ICON_SIZE = 1024;
const APP_ICON_PADDING = 128;
const APP_ICON_BG = "#0d1117";
const APP_ICON_FG = "#58a6ff";
+const DASHBOARD_PWA_ICON_BG = "#1a1a2e";
+const DASHBOARD_PWA_ICON_FG = APP_ICON_FG;
+
+async function renderIconTile(options: {
+ tintedSvg: string;
+ size: number;
+ padding: number;
+ background: string;
+ outputPath: string;
+}): Promise {
+ const markSize = options.size - options.padding * 2;
+ const mark = await sharp(Buffer.from(options.tintedSvg), { density: 2048 })
+ .resize(markSize, markSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
+ .png()
+ .toBuffer();
+
+ await sharp({
+ create: {
+ width: options.size,
+ height: options.size,
+ channels: 4,
+ background: options.background,
+ },
+ })
+ .composite([{ input: mark, top: options.padding, left: options.padding }])
+ .png({ compressionLevel: 9 })
+ .toFile(options.outputPath);
+}
async function main(): Promise {
const sourceSvg = await readFile(sourceSvgPath, "utf8");
const trayTintedSvg = sourceSvg.replaceAll("currentColor", "#333333");
const appTintedSvg = sourceSvg.replaceAll("currentColor", APP_ICON_FG);
+ const dashboardPwaTintedSvg = sourceSvg.replaceAll("currentColor", DASHBOARD_PWA_ICON_FG);
await mkdir(outputDir, { recursive: true });
+ await mkdir(dashboardPwaIconDir, { recursive: true });
await Promise.all(
iconSizes.map(async (size) => {
@@ -34,25 +66,34 @@ async function main(): Promise {
}),
);
- const markSize = APP_ICON_SIZE - APP_ICON_PADDING * 2;
- const mark = await sharp(Buffer.from(appTintedSvg), { density: 2048 })
- .resize(markSize, markSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
- .png()
- .toBuffer();
+ await renderIconTile({
+ tintedSvg: appTintedSvg,
+ size: APP_ICON_SIZE,
+ padding: APP_ICON_PADDING,
+ background: APP_ICON_BG,
+ outputPath: resolve(outputDir, "icon.png"),
+ });
- await sharp({
- create: {
- width: APP_ICON_SIZE,
- height: APP_ICON_SIZE,
- channels: 4,
- background: APP_ICON_BG,
- },
- })
- .composite([{ input: mark, top: APP_ICON_PADDING, left: APP_ICON_PADDING }])
- .png({ compressionLevel: 9 })
- .toFile(resolve(outputDir, "icon.png"));
+ /*
+ FNXC:DashboardPWAIcons 2026-06-16-21:10:
+ Dashboard mobile home-screen icons must be derived from packages/dashboard/app/public/logo.svg so the installed PWA, iOS apple-touch tile, and Android launcher glyph stay aligned with the in-app Fusion brand mark.
+ Regenerate these assets and bump the dashboard service-worker cache whenever the canonical logo.svg brand mark changes.
+ */
+ await Promise.all(
+ dashboardPwaIconSizes.map(async (size) => {
+ await renderIconTile({
+ tintedSvg: dashboardPwaTintedSvg,
+ size,
+ padding: Math.round(size * (APP_ICON_PADDING / APP_ICON_SIZE)),
+ background: DASHBOARD_PWA_ICON_BG,
+ outputPath: resolve(dashboardPwaIconDir, `icon-${size}.png`),
+ });
+ }),
+ );
- console.log(`Generated ${iconSizes.length} tray icons and app icon in ${outputDir}`);
+ console.log(
+ `Generated ${iconSizes.length} tray icons and app icon in ${outputDir}; ${dashboardPwaIconSizes.length} dashboard PWA icons in ${dashboardPwaIconDir}`,
+ );
}
void main();