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