feat(desktop): stamp dev icons with branch labels

This commit is contained in:
Mohamed Boudra
2026-08-19 11:54:55 +02:00
parent 556bd0d36f
commit ca2ee380df
6 changed files with 410 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

@@ -1,4 +1,4 @@
export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) { export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl, devBuildLabel }) {
return { return {
// Electron must stay in the runner's process group. Paseo workspace scripts // Electron must stay in the runner's process group. Paseo workspace scripts
// own the terminal process group, so detaching Electron lets it survive a // own the terminal process group, so detaching Electron lets it survive a
@@ -8,6 +8,7 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
...env, ...env,
...colorEnv, ...colorEnv,
EXPO_DEV_URL: expoDevUrl, EXPO_DEV_URL: expoDevUrl,
...(devBuildLabel ? { EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL: devBuildLabel } : {}),
}, },
}; };
} }
+1
View File
@@ -195,6 +195,7 @@ if (!stopping) {
env: process.env, env: process.env,
colorEnv, colorEnv,
expoDevUrl, expoDevUrl,
devBuildLabel,
}), }),
); );
} }
@@ -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();
});
});
});
@@ -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<string> {
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<string | null> {
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<string | null> {
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;
}
+38 -5
View File
@@ -47,6 +47,7 @@ import {
} from "./features/notifications.js"; } from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js"; import { registerOpenerHandlers } from "./features/opener.js";
import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js"; import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js";
import { resolveAppIconPath } from "./features/stamped-icon.js";
import { setupApplicationMenu } from "./features/menu.js"; import { setupApplicationMenu } from "./features/menu.js";
import { import {
BROWSER_NEW_TAB_REQUEST_EVENT, BROWSER_NEW_TAB_REQUEST_EVENT,
@@ -646,11 +647,15 @@ function getWindowIconCandidates(): string[] {
} }
if (process.platform === "win32") { if (process.platform === "win32") {
return [ return [
path.resolve(__dirname, "../assets/icon-dev.png"),
path.resolve(__dirname, "../assets/icon.ico"), path.resolve(__dirname, "../assets/icon.ico"),
path.resolve(__dirname, "../assets/icon.png"), 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 { function getWindowIconPath(): string | null {
@@ -658,12 +663,40 @@ function getWindowIconPath(): string | null {
return candidates.find((candidate) => existsSync(candidate)) ?? 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<string | null> {
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<void> {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
return; return;
} }
const iconPath = getWindowIconPath(); const iconPath = await getEffectiveAppIconPath();
if (!iconPath) { if (!iconPath) {
return; return;
} }
@@ -691,7 +724,7 @@ async function createWindow(
restoreWindowState?: boolean; restoreWindowState?: boolean;
} = {}, } = {},
): Promise<BrowserWindow> { ): Promise<BrowserWindow> {
const iconPath = getWindowIconPath(); const iconPath = await getEffectiveAppIconPath();
const systemTheme = resolveSystemWindowTheme(); const systemTheme = resolveSystemWindowTheme();
// Only the first window of a session restores and persists saved geometry. // Only the first window of a session restores and persists saved geometry.
@@ -972,7 +1005,7 @@ async function bootstrap(): Promise<void> {
return net.fetch(pathToFileURL(filePath).toString()); return net.fetch(pathToFileURL(filePath).toString());
}); });
applyAppIcon(); await applyAppIcon();
setupApplicationMenu({ setupApplicationMenu({
onNewWindow: () => { onNewWindow: () => {
void createWindow().catch((error) => { void createWindow().catch((error) => {