diff --git a/packages/desktop/assets/icon-dev.png b/packages/desktop/assets/icon-dev.png new file mode 100644 index 000000000..00ba1cc22 Binary files /dev/null and b/packages/desktop/assets/icon-dev.png differ diff --git a/packages/desktop/scripts/dev-runner-config.mjs b/packages/desktop/scripts/dev-runner-config.mjs index c855b3e5d..706728414 100644 --- a/packages/desktop/scripts/dev-runner-config.mjs +++ b/packages/desktop/scripts/dev-runner-config.mjs @@ -1,4 +1,4 @@ -export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) { +export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl, devBuildLabel }) { return { // Electron must stay in the runner's process group. Paseo workspace scripts // own the terminal process group, so detaching Electron lets it survive a @@ -8,6 +8,7 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) { ...env, ...colorEnv, EXPO_DEV_URL: expoDevUrl, + ...(devBuildLabel ? { EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL: devBuildLabel } : {}), }, }; } diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 5e0344c0d..852bf7d85 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -195,6 +195,7 @@ if (!stopping) { env: process.env, colorEnv, expoDevUrl, + devBuildLabel, }), ); } diff --git a/packages/desktop/src/features/stamped-icon.test.ts b/packages/desktop/src/features/stamped-icon.test.ts new file mode 100644 index 000000000..a6e7bc578 --- /dev/null +++ b/packages/desktop/src/features/stamped-icon.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + buildImageMagickArgs, + computeStampGeometry, + resolveAppIconPath, + resolveMonospaceFont, +} from "./stamped-icon"; + +describe("stamped-icon", () => { + describe("resolveMonospaceFont", () => { + it("selects Menlo-Bold on macOS when present", () => { + const mockList = ` + Font: Helvetica-Bold + Font: Menlo-Bold + Font: Courier-New-Bold + `; + expect(resolveMonospaceFont(mockList)).toBe("Menlo-Bold"); + }); + + it("selects Consolas-Bold on Windows when Menlo is absent", () => { + const mockList = ` + Font: Arial + Font: Consolas-Bold + Font: Courier-New + `; + expect(resolveMonospaceFont(mockList)).toBe("Consolas-Bold"); + }); + + it("selects DejaVu-Sans-Mono-Bold on Linux", () => { + const mockList = ` + Font: DejaVu-Sans-Mono-Bold + Font: FreeMono + `; + expect(resolveMonospaceFont(mockList)).toBe("DejaVu-Sans-Mono-Bold"); + }); + + it("falls back to Courier when no preferred font matches", () => { + const mockList = ` + Font: SomeUnknownFont + `; + expect(resolveMonospaceFont(mockList)).toBe("Courier"); + }); + }); + + describe("computeStampGeometry", () => { + it("computes lowercase geometry for short label", () => { + const geo = computeStampGeometry(512, 512, "DEV"); + expect(geo.label).toBe("dev"); + expect(geo.pointsize).toBeGreaterThan(0); + expect(geo.x1).toBeGreaterThan(0); + expect(geo.x2).toBeLessThan(512); + expect(geo.y1).toBeGreaterThan(0); + expect(geo.y2).toBeLessThan(512); + }); + + it("scales geometry for large images", () => { + const geo512 = computeStampGeometry(512, 512, "fix/desktop-stamp"); + const geo1024 = computeStampGeometry(1024, 1024, "fix/desktop-stamp"); + + expect(geo1024.pointsize).toBeGreaterThan(geo512.pointsize); + expect(geo1024.r).toBeGreaterThan(geo512.r); + }); + }); + + describe("buildImageMagickArgs", () => { + it("builds valid composite args", () => { + const geo = computeStampGeometry(512, 512, "test-branch"); + const args = buildImageMagickArgs("/in.png", "/out.png", geo, "Menlo-Bold", 512, 512); + + expect(args[0]).toBe("/in.png"); + expect(args[args.length - 1]).toBe("/out.png"); + expect(args).toContain("Menlo-Bold"); + expect(args).toContain("test-branch"); + expect(args).toContain("DstIn"); + expect(args).toContain("Over"); + }); + }); + + describe("resolveAppIconPath", () => { + it("returns base icon path immediately when packaged", async () => { + const result = await resolveAppIconPath({ + isPackaged: true, + baseIconPath: "/path/to/icon.png", + devLabel: "feature/foo", + cacheDir: "/cache", + }); + expect(result).toBe("/path/to/icon.png"); + }); + + it("returns base icon path when no dev label is provided", async () => { + const result = await resolveAppIconPath({ + isPackaged: false, + baseIconPath: "/path/to/icon.png", + devLabel: null, + cacheDir: "/cache", + }); + expect(result).toBe("/path/to/icon.png"); + }); + + it("returns null if baseIconPath is null", async () => { + const result = await resolveAppIconPath({ + isPackaged: false, + baseIconPath: null, + devLabel: "dev", + cacheDir: "/cache", + }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/desktop/src/features/stamped-icon.ts b/packages/desktop/src/features/stamped-icon.ts new file mode 100644 index 000000000..86a67a249 --- /dev/null +++ b/packages/desktop/src/features/stamped-icon.ts @@ -0,0 +1,259 @@ +import { execFile } from "node:child_process"; +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const STAMP_CACHE_VERSION = "v2"; + +export const MONOSPACE_FONT_CANDIDATES = [ + "Menlo-Bold", + "SFMono-Bold", + "Consolas-Bold", + "DejaVu-Sans-Mono-Bold", + "Liberation-Mono-Bold", + "Courier-New-Bold", + "Courier-Bold", + "Courier", +] as const; + +export interface StampGeometry { + label: string; + x1: number; + y1: number; + x2: number; + y2: number; + r: number; + pointsize: number; + offsetY: number; +} + +export function resolveMonospaceFont(fontsListOutput: string): string { + for (const candidate of MONOSPACE_FONT_CANDIDATES) { + const regex = new RegExp(`^\\s*Font:\\s*${candidate}\\b`, "im"); + if (regex.test(fontsListOutput)) { + return candidate; + } + } + return "Courier"; +} + +export function computeStampGeometry( + width: number, + height: number, + rawLabel: string, +): StampGeometry { + const label = rawLabel.trim().toLowerCase(); + const ph = Math.max(24, Math.round(height * 0.1)); + const maxPointSize = Math.round(ph * 1.03); + const minPointSize = 12; + const pointsize = Math.max( + minPointSize, + Math.min(maxPointSize, Math.round((width * 0.8) / Math.max(1, label.length * 0.6))), + ); + const textWidth = label.length * (pointsize * 0.6); + const paddingX = Math.round(ph * 0.7); + const pw = Math.min( + Math.round(width * 0.88), + Math.max(Math.round(width * 0.35), Math.round(textWidth + paddingX * 2)), + ); + const centerY = Math.round(height * 0.81); + const y1 = Math.round(centerY - ph / 2); + const y2 = Math.round(centerY + ph / 2); + const x1 = Math.round((width - pw) / 2); + const x2 = Math.round((width + pw) / 2); + const r = Math.round(ph / 2); + const offsetY = Math.round(centerY - height / 2); + + return { + label, + x1, + y1, + x2, + y2, + r, + pointsize, + offsetY, + }; +} + +export function buildImageMagickArgs( + inputPath: string, + outputPath: string, + geometry: StampGeometry, + font: string, + width: number, + height: number, +): string[] { + return [ + inputPath, + "(", + "-clone", + "0", + "-alpha", + "extract", + ")", + "(", + "-size", + `${width}x${height}`, + "xc:none", + "-fill", + "rgba(10,12,18,0.88)", + "-draw", + `roundrectangle ${geometry.x1},${geometry.y1} ${geometry.x2},${geometry.y2} ${geometry.r},${geometry.r}`, + "-gravity", + "center", + "-font", + font, + "-pointsize", + String(geometry.pointsize), + "-fill", + "#f0f0f5", + "-annotate", + `+0+${geometry.offsetY}`, + geometry.label, + ")", + "(", + "-clone", + "2", + "-clone", + "1", + "-compose", + "DstIn", + "-composite", + ")", + "-delete", + "1,2", + "-compose", + "Over", + "-composite", + outputPath, + ]; +} + +let cachedMonospaceFont: string | null = null; + +async function detectMonospaceFont(magickBin: string): Promise { + if (cachedMonospaceFont) { + return cachedMonospaceFont; + } + try { + const { stdout } = await execFileAsync(magickBin, ["-list", "font"], { + encoding: "utf-8", + timeout: 3000, + windowsHide: true, + }); + cachedMonospaceFont = resolveMonospaceFont(stdout); + } catch { + cachedMonospaceFont = "Courier"; + } + return cachedMonospaceFont; +} + +function sanitizeLabelForFilename(label: string): string { + return label + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +async function getImageDimensions( + magickBin: string, + filePath: string, +): Promise<{ width: number; height: number } | null> { + try { + const { stdout } = await execFileAsync(magickBin, ["identify", "-format", "%wx%h", filePath], { + encoding: "utf-8", + timeout: 3000, + windowsHide: true, + }); + const match = stdout.trim().match(/^(\d+)x(\d+)$/); + if (match) { + return { width: Number.parseInt(match[1], 10), height: Number.parseInt(match[2], 10) }; + } + } catch { + // fallback if identify fails + } + return null; +} + +export async function getStampedIconPath(options: { + baseIconPath: string; + label: string; + cacheDir: string; +}): Promise { + const { baseIconPath, label, cacheDir } = options; + if (!label || !existsSync(baseIconPath)) { + return null; + } + + const safeLabel = sanitizeLabelForFilename(label) || "dev"; + const baseName = path.basename(baseIconPath, path.extname(baseIconPath)); + const outputPath = path.join( + cacheDir, + `icon-stamp-${STAMP_CACHE_VERSION}-${baseName}-${safeLabel}.png`, + ); + + if (existsSync(outputPath)) { + return outputPath; + } + + const magickBin = process.platform === "win32" ? "magick.exe" : "magick"; + try { + const dimensions = (await getImageDimensions(magickBin, baseIconPath)) ?? { + width: 512, + height: 512, + }; + const font = await detectMonospaceFont(magickBin); + const geometry = computeStampGeometry(dimensions.width, dimensions.height, label); + + await fs.mkdir(cacheDir, { recursive: true }); + const args = buildImageMagickArgs( + baseIconPath, + outputPath, + geometry, + font, + dimensions.width, + dimensions.height, + ); + + await execFileAsync(magickBin, args, { + timeout: 6000, + windowsHide: true, + }); + + if (existsSync(outputPath)) { + return outputPath; + } + } catch { + // Graceful degradation: dev icon stamping is optional and fails silently to base icon. + } + + return null; +} + +export async function resolveAppIconPath(options: { + isPackaged: boolean; + baseIconPath: string | null; + devLabel: string | null; + cacheDir: string; +}): Promise { + const { isPackaged, baseIconPath, devLabel, cacheDir } = options; + if (!baseIconPath) { + return null; + } + + if (isPackaged || !devLabel) { + return baseIconPath; + } + + const stamped = await getStampedIconPath({ + baseIconPath, + label: devLabel, + cacheDir, + }); + + return stamped ?? baseIconPath; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index da3630ff4..399272402 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -47,6 +47,7 @@ import { } from "./features/notifications.js"; import { registerOpenerHandlers } from "./features/opener.js"; import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js"; +import { resolveAppIconPath } from "./features/stamped-icon.js"; import { setupApplicationMenu } from "./features/menu.js"; import { BROWSER_NEW_TAB_REQUEST_EVENT, @@ -646,11 +647,15 @@ function getWindowIconCandidates(): string[] { } if (process.platform === "win32") { return [ + path.resolve(__dirname, "../assets/icon-dev.png"), path.resolve(__dirname, "../assets/icon.ico"), path.resolve(__dirname, "../assets/icon.png"), ]; } - return [path.resolve(__dirname, "../assets/icon.png")]; + return [ + path.resolve(__dirname, "../assets/icon-dev.png"), + path.resolve(__dirname, "../assets/icon.png"), + ]; } function getWindowIconPath(): string | null { @@ -658,12 +663,40 @@ function getWindowIconPath(): string | null { return candidates.find((candidate) => existsSync(candidate)) ?? null; } -function applyAppIcon(): void { +function getDevBuildLabel(): string | null { + if (app.isPackaged) { + return null; + } + return process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; +} + +let cachedEffectiveIconPath: string | null = null; + +async function getEffectiveAppIconPath(): Promise { + if (cachedEffectiveIconPath !== null) { + return cachedEffectiveIconPath; + } + const baseIconPath = getWindowIconPath(); + if (app.isPackaged || !baseIconPath) { + cachedEffectiveIconPath = baseIconPath; + return baseIconPath; + } + const devLabel = getDevBuildLabel(); + cachedEffectiveIconPath = await resolveAppIconPath({ + isPackaged: false, + baseIconPath, + devLabel, + cacheDir: app.getPath("userData"), + }); + return cachedEffectiveIconPath; +} + +async function applyAppIcon(): Promise { if (process.platform !== "darwin") { return; } - const iconPath = getWindowIconPath(); + const iconPath = await getEffectiveAppIconPath(); if (!iconPath) { return; } @@ -691,7 +724,7 @@ async function createWindow( restoreWindowState?: boolean; } = {}, ): Promise { - const iconPath = getWindowIconPath(); + const iconPath = await getEffectiveAppIconPath(); const systemTheme = resolveSystemWindowTheme(); // Only the first window of a session restores and persists saved geometry. @@ -972,7 +1005,7 @@ async function bootstrap(): Promise { return net.fetch(pathToFileURL(filePath).toString()); }); - applyAppIcon(); + await applyAppIcon(); setupApplicationMenu({ onNewWindow: () => { void createWindow().catch((error) => {