feat(desktop): stamp dev icons with branch labels
This commit is contained in:
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 {
|
||||
// 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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ if (!stopping) {
|
||||
env: process.env,
|
||||
colorEnv,
|
||||
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;
|
||||
}
|
||||
@@ -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<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") {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = getWindowIconPath();
|
||||
const iconPath = await getEffectiveAppIconPath();
|
||||
if (!iconPath) {
|
||||
return;
|
||||
}
|
||||
@@ -691,7 +724,7 @@ async function createWindow(
|
||||
restoreWindowState?: boolean;
|
||||
} = {},
|
||||
): Promise<BrowserWindow> {
|
||||
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<void> {
|
||||
return net.fetch(pathToFileURL(filePath).toString());
|
||||
});
|
||||
|
||||
applyAppIcon();
|
||||
await applyAppIcon();
|
||||
setupApplicationMenu({
|
||||
onNewWindow: () => {
|
||||
void createWindow().catch((error) => {
|
||||
|
||||
Reference in New Issue
Block a user