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 {
|
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 } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user