refactor: migrate to VS Code titlebar drag region pattern
This commit is contained in:
@@ -7,6 +7,24 @@ const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223";
|
||||
const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification";
|
||||
const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081";
|
||||
const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"];
|
||||
const INTERACTIVE_SELECTOR = [
|
||||
"button",
|
||||
"a",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[role='textbox']",
|
||||
"[role='combobox']",
|
||||
"[role='tab']",
|
||||
"[role='switch']",
|
||||
"[role='checkbox']",
|
||||
"[role='slider']",
|
||||
"[role='menuitem']",
|
||||
"[tabindex]",
|
||||
"[contenteditable='true']",
|
||||
].join(", ");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -28,6 +46,313 @@ async function captureScreenshot(page, fileName) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function inspectTitlebarRegions(page) {
|
||||
return page.evaluate((interactiveSelector) => {
|
||||
const nodes = Array.from(document.querySelectorAll("*"));
|
||||
const annotationId = "electron-verify-titlebar-style";
|
||||
const existingAnnotation = document.getElementById(annotationId);
|
||||
existingAnnotation?.remove();
|
||||
|
||||
const annotationStyle = document.createElement("style");
|
||||
annotationStyle.id = annotationId;
|
||||
annotationStyle.textContent = `
|
||||
[data-electron-verify-drag="true"] {
|
||||
outline: 3px solid #ff4d4f !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
[data-electron-verify-resizer="true"] {
|
||||
outline: 3px solid #52c41a !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
[data-electron-verify-interactive="true"] {
|
||||
outline: 3px solid #1677ff !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(annotationStyle);
|
||||
|
||||
function isVisible(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeText(element) {
|
||||
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
}
|
||||
|
||||
function readAppRegion(element) {
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
|
||||
}
|
||||
|
||||
function rectInfo(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeElement(element) {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
text: summarizeText(element),
|
||||
appRegion: readAppRegion(element),
|
||||
position: style.position,
|
||||
zIndex: style.zIndex,
|
||||
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||
...rectInfo(element),
|
||||
};
|
||||
}
|
||||
|
||||
function isNearTop(summary) {
|
||||
return summary.top < 220;
|
||||
}
|
||||
|
||||
function isTopResizer(element, overlayRect) {
|
||||
if (!(element instanceof HTMLElement) || !isVisible(element)) {
|
||||
return false;
|
||||
}
|
||||
const summary = summarizeElement(element);
|
||||
return (
|
||||
summary.appRegion === "no-drag" &&
|
||||
summary.position === "absolute" &&
|
||||
Math.abs(summary.height - 4) <= 1 &&
|
||||
Math.abs(summary.top - overlayRect.top) <= 2 &&
|
||||
Math.abs(summary.left - overlayRect.left) <= 2 &&
|
||||
Math.abs(summary.width - overlayRect.width) <= 2
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeInteractive(element) {
|
||||
const summary = summarizeElement(element);
|
||||
return {
|
||||
...summary,
|
||||
testId: element.getAttribute("data-testid"),
|
||||
role: element.getAttribute("role"),
|
||||
};
|
||||
}
|
||||
|
||||
const dragSummaries = [];
|
||||
const suspiciousDragHosts = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||||
continue;
|
||||
}
|
||||
const summary = summarizeElement(node);
|
||||
if (summary.appRegion !== "drag") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = node.parentElement instanceof HTMLElement ? node.parentElement : null;
|
||||
const parentSummary = parent ? summarizeElement(parent) : null;
|
||||
const interactiveDescendants = Array.from(node.querySelectorAll(interactiveSelector))
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isVisible(child))
|
||||
.map((child) => summarizeInteractive(child));
|
||||
|
||||
const siblingResizers = parent
|
||||
? Array.from(parent.children)
|
||||
.filter((child) => child !== node)
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isTopResizer(child, summary))
|
||||
.map((child) => summarizeElement(child))
|
||||
: [];
|
||||
|
||||
const parentInteractive = parent
|
||||
? Array.from(parent.querySelectorAll(interactiveSelector))
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isVisible(child))
|
||||
.map((child) => summarizeInteractive(child))
|
||||
: [];
|
||||
const explicitNoDragInteractive = parentInteractive.filter(
|
||||
(child) => child.appRegion === "no-drag",
|
||||
);
|
||||
|
||||
const record = {
|
||||
...summary,
|
||||
parent: parentSummary,
|
||||
interactiveDescendants: interactiveDescendants.slice(0, 5),
|
||||
siblingResizers: siblingResizers.slice(0, 3),
|
||||
explicitNoDragInteractive: explicitNoDragInteractive.slice(0, 5),
|
||||
parentInteractiveCount: parentInteractive.length,
|
||||
};
|
||||
dragSummaries.push(record);
|
||||
|
||||
const looksLikeHostShortcut =
|
||||
isNearTop(summary) &&
|
||||
(summary.position !== "absolute" ||
|
||||
summary.text.length > 0 ||
|
||||
interactiveDescendants.length > 0 ||
|
||||
parentSummary?.appRegion === "drag");
|
||||
if (looksLikeHostShortcut) {
|
||||
suspiciousDragHosts.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
const verifiedRegions = dragSummaries
|
||||
.filter((entry) => isNearTop(entry))
|
||||
.filter((entry) => entry.position === "absolute")
|
||||
.filter((entry) => entry.text.length === 0)
|
||||
.filter((entry) => entry.parent?.appRegion !== "drag")
|
||||
.filter((entry) => entry.siblingResizers.length > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.explicitNoDragInteractive.length - left.explicitNoDragInteractive.length ||
|
||||
left.top - right.top ||
|
||||
right.width - left.width,
|
||||
);
|
||||
|
||||
const candidate = verifiedRegions[0] ?? null;
|
||||
if (candidate) {
|
||||
const matchingDragNode = nodes.find((node) => {
|
||||
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||||
return false;
|
||||
}
|
||||
const summary = summarizeElement(node);
|
||||
return (
|
||||
summary.appRegion === "drag" &&
|
||||
Math.abs(summary.top - candidate.top) <= 1 &&
|
||||
Math.abs(summary.left - candidate.left) <= 1 &&
|
||||
Math.abs(summary.width - candidate.width) <= 1 &&
|
||||
Math.abs(summary.height - candidate.height) <= 1
|
||||
);
|
||||
});
|
||||
if (matchingDragNode instanceof HTMLElement) {
|
||||
matchingDragNode.setAttribute("data-electron-verify-drag", "true");
|
||||
const parent = matchingDragNode.parentElement;
|
||||
if (parent instanceof HTMLElement) {
|
||||
for (const child of parent.children) {
|
||||
if (child instanceof HTMLElement && isTopResizer(child, candidate)) {
|
||||
child.setAttribute("data-electron-verify-resizer", "true");
|
||||
}
|
||||
}
|
||||
const interactiveChildren = Array.from(parent.querySelectorAll(interactiveSelector))
|
||||
.filter((child) => child instanceof HTMLElement)
|
||||
.filter((child) => isVisible(child))
|
||||
.filter((child) => summarizeElement(child).appRegion === "no-drag")
|
||||
.slice(0, 3);
|
||||
for (const child of interactiveChildren) {
|
||||
child.setAttribute("data-electron-verify-interactive", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interactiveSelector,
|
||||
dragRegionCount: dragSummaries.length,
|
||||
verifiedRegionCount: verifiedRegions.length,
|
||||
candidate,
|
||||
suspiciousDragHosts: suspiciousDragHosts.slice(0, 10),
|
||||
dragRegions: dragSummaries.slice(0, 10),
|
||||
};
|
||||
}, INTERACTIVE_SELECTOR);
|
||||
}
|
||||
|
||||
async function inspectFullscreenResizer(page) {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
let windowId = null;
|
||||
let initialBounds = null;
|
||||
let fullscreenEntered = false;
|
||||
|
||||
try {
|
||||
const windowInfo = await session.send("Browser.getWindowForTarget");
|
||||
windowId = windowInfo.windowId;
|
||||
initialBounds = await session.send("Browser.getWindowBounds", { windowId });
|
||||
await session.send("Browser.setWindowBounds", {
|
||||
windowId,
|
||||
bounds: { windowState: "fullscreen" },
|
||||
});
|
||||
fullscreenEntered = true;
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const details = await page.evaluate(async () => {
|
||||
const bridge = window.paseoDesktop?.window;
|
||||
const bridgeFullscreen =
|
||||
typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null;
|
||||
const visibleNoDragResizers = Array.from(document.querySelectorAll("*"))
|
||||
.filter((node) => node instanceof HTMLElement)
|
||||
.filter((node) => {
|
||||
const element = node;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion =
|
||||
style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none";
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0" &&
|
||||
appRegion === "no-drag" &&
|
||||
style.position === "absolute" &&
|
||||
Math.abs(rect.height - 4) <= 1 &&
|
||||
rect.top < 220
|
||||
);
|
||||
})
|
||||
.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
tagName: node.tagName.toLowerCase(),
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
bridgeFullscreen,
|
||||
visibleNoDragResizers,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
enteredFullscreen: fullscreenEntered,
|
||||
initialBounds,
|
||||
...details,
|
||||
passed:
|
||||
details.bridgeFullscreen === true &&
|
||||
Array.isArray(details.visibleNoDragResizers) &&
|
||||
details.visibleNoDragResizers.length === 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: false,
|
||||
error: String(error),
|
||||
initialBounds,
|
||||
};
|
||||
} finally {
|
||||
const previousWindowState = initialBounds?.bounds?.windowState ?? "normal";
|
||||
if (windowId !== null && fullscreenEntered) {
|
||||
try {
|
||||
await session.send("Browser.setWindowBounds", {
|
||||
windowId,
|
||||
bounds: { windowState: previousWindowState },
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
} catch {
|
||||
// Best-effort restore only.
|
||||
}
|
||||
}
|
||||
await session.detach().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function findAppPage(browser) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
for (const context of browser.contexts()) {
|
||||
@@ -132,115 +457,20 @@ async function main() {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const dragRegionCheck = await page.evaluate(() => {
|
||||
const nodes = Array.from(document.querySelectorAll("*"));
|
||||
const annotationId = "electron-verify-drag-style";
|
||||
const existingAnnotation = document.getElementById(annotationId);
|
||||
existingAnnotation?.remove();
|
||||
|
||||
const annotationStyle = document.createElement("style");
|
||||
annotationStyle.id = annotationId;
|
||||
annotationStyle.textContent = `
|
||||
[data-electron-verify-drag="true"] {
|
||||
outline: 3px solid #ff4d4f !important;
|
||||
outline-offset: -3px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(annotationStyle);
|
||||
|
||||
function isVisible(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
style.opacity !== "0"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeText(element) {
|
||||
return (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
}
|
||||
|
||||
const regions = [];
|
||||
let candidate = null;
|
||||
|
||||
for (const element of nodes) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||
if (appRegion !== "drag" || !isVisible(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const text = summarizeText(element);
|
||||
const info = {
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
text,
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
paddingLeft: Number.parseFloat(style.paddingLeft || "0"),
|
||||
paddingTop: Number.parseFloat(style.paddingTop || "0"),
|
||||
};
|
||||
regions.push(info);
|
||||
|
||||
const looksLikeHeader =
|
||||
rect.top < 180 &&
|
||||
rect.height >= 40 &&
|
||||
(text.includes("Settings") || text.includes("Sessions"));
|
||||
if (!candidate && looksLikeHeader) {
|
||||
candidate = { ...info };
|
||||
element.setAttribute("data-electron-verify-drag", "true");
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate && regions.length > 0) {
|
||||
candidate = regions.slice().sort((left, right) => left.top - right.top)[0];
|
||||
|
||||
for (const element of nodes) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const appRegion = style.webkitAppRegion || style.getPropertyValue("-webkit-app-region");
|
||||
if (appRegion !== "drag" || !isVisible(element)) {
|
||||
continue;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
Math.abs(rect.top - candidate.top) < 1 &&
|
||||
Math.abs(rect.left - candidate.left) < 1 &&
|
||||
Math.abs(rect.width - candidate.width) < 1 &&
|
||||
Math.abs(rect.height - candidate.height) < 1
|
||||
) {
|
||||
element.setAttribute("data-electron-verify-drag", "true");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: regions.length,
|
||||
candidate,
|
||||
regions: regions.slice(0, 10),
|
||||
};
|
||||
});
|
||||
const dragRegionCheck = await inspectTitlebarRegions(page);
|
||||
|
||||
const dragScreenshot = await captureScreenshot(page, "03-drag-region.png");
|
||||
const dragRegionPassed =
|
||||
dragRegionCheck.count > 0 &&
|
||||
dragRegionCheck.dragRegionCount > 0 &&
|
||||
dragRegionCheck.verifiedRegionCount > 0 &&
|
||||
Boolean(dragRegionCheck.candidate) &&
|
||||
dragRegionCheck.candidate.top < 180;
|
||||
dragRegionCheck.candidate.top < 220 &&
|
||||
dragRegionCheck.candidate.parent?.appRegion !== "drag" &&
|
||||
dragRegionCheck.candidate.siblingResizers.length > 0 &&
|
||||
dragRegionCheck.suspiciousDragHosts.length === 0;
|
||||
|
||||
results.push({
|
||||
check: "drag-regions",
|
||||
check: "titlebar-drag-structure",
|
||||
pass: dragRegionPassed,
|
||||
details: dragRegionCheck,
|
||||
screenshot: dragScreenshot,
|
||||
@@ -248,7 +478,7 @@ async function main() {
|
||||
|
||||
const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png");
|
||||
const isMac = process.platform === "darwin";
|
||||
const observedPaddingLeft = dragRegionCheck.candidate?.paddingLeft ?? null;
|
||||
const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null;
|
||||
const trafficLightPaddingPassed = !isMac
|
||||
? true
|
||||
: typeof observedPaddingLeft === "number" &&
|
||||
@@ -261,12 +491,36 @@ async function main() {
|
||||
details: {
|
||||
platform: process.platform,
|
||||
observedPaddingLeft,
|
||||
expectedApproximatePaddingLeft: 78,
|
||||
note: "Traffic-light padding is only validated structurally on macOS in this verifier.",
|
||||
candidate: dragRegionCheck.candidate,
|
||||
},
|
||||
screenshot: trafficLightScreenshot,
|
||||
});
|
||||
|
||||
const noDragInteractiveCheck = {
|
||||
check: "interactive-no-drag-layering",
|
||||
pass:
|
||||
Boolean(dragRegionCheck.candidate) &&
|
||||
Array.isArray(dragRegionCheck.candidate.explicitNoDragInteractive) &&
|
||||
dragRegionCheck.candidate.explicitNoDragInteractive.length > 0,
|
||||
details: {
|
||||
candidate: dragRegionCheck.candidate,
|
||||
explicitNoDragInteractive:
|
||||
dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
|
||||
},
|
||||
screenshot: dragScreenshot,
|
||||
};
|
||||
results.push(noDragInteractiveCheck);
|
||||
|
||||
const fullscreenDetails = await inspectFullscreenResizer(page);
|
||||
const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png");
|
||||
results.push({
|
||||
check: "fullscreen-resizer",
|
||||
pass: fullscreenDetails.supported ? fullscreenDetails.passed : true,
|
||||
details: fullscreenDetails,
|
||||
screenshot: fullscreenScreenshot,
|
||||
});
|
||||
|
||||
const daemonManagementVisible = await Promise.all([
|
||||
page.getByText("Built-in daemon", { exact: true }).isVisible(),
|
||||
page.getByText("Daemon management", { exact: true }).isVisible(),
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||
import {
|
||||
registerWindowManager,
|
||||
getTitleBarOverlayOptions,
|
||||
getMainWindowChromeOptions,
|
||||
resolveSystemWindowTheme,
|
||||
setupWindowResizeEvents,
|
||||
setupDragDropPrevention,
|
||||
@@ -86,21 +86,18 @@ function applyAppIcon(): void {
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
|
||||
autoHideMenuBar: true,
|
||||
}),
|
||||
...getMainWindowChromeOptions({
|
||||
platform: process.platform,
|
||||
theme: systemTheme,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
window: {
|
||||
getCurrentWindow: () => ({
|
||||
startMove: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
|
||||
moving: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
|
||||
endMove: () => ipcRenderer.send("paseo:window:endMove"),
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
setTitleBarTheme: (theme: "light" | "dark") =>
|
||||
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
|
||||
updateWindowControls: (update: {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
|
||||
onResized: (handler: EventHandler): (() => void) => {
|
||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||
handler(payload);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createWindowMoveState,
|
||||
readWindowMovePayload,
|
||||
resolveWindowMovePosition,
|
||||
} from "./window-drag-coordinates";
|
||||
|
||||
describe("window-drag-coordinates", () => {
|
||||
describe("readWindowMovePayload", () => {
|
||||
it("returns finite screen coordinates", () => {
|
||||
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
|
||||
screenX: 1500,
|
||||
screenY: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite screen coordinates", () => {
|
||||
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull();
|
||||
expect(
|
||||
readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWindowMoveState", () => {
|
||||
it("derives finite offsets from the window position", () => {
|
||||
expect(
|
||||
createWindowMoveState({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
windowX: 1200,
|
||||
windowY: 700,
|
||||
}),
|
||||
).toEqual({
|
||||
offsetX: 300,
|
||||
offsetY: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite offsets", () => {
|
||||
expect(
|
||||
createWindowMoveState({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
windowX: Number.NaN,
|
||||
windowY: 700,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWindowMovePosition", () => {
|
||||
it("rounds the next window position", () => {
|
||||
expect(
|
||||
resolveWindowMovePosition({
|
||||
payload: { screenX: 1501.4, screenY: 902.6 },
|
||||
state: { offsetX: 300, offsetY: 200 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 1201,
|
||||
y: 703,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite next positions", () => {
|
||||
expect(
|
||||
resolveWindowMovePosition({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
state: { offsetX: Number.NaN, offsetY: 200 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
export type WindowMovePayload = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
export type WindowMoveState = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
export type WindowPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type WindowMovePayloadInput =
|
||||
| {
|
||||
screenX?: unknown;
|
||||
screenY?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
|
||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
screenX: input.screenX,
|
||||
screenY: input.screenY,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWindowMoveState(input: {
|
||||
payload: WindowMovePayload;
|
||||
windowX: number;
|
||||
windowY: number;
|
||||
}): WindowMoveState | null {
|
||||
const offsetX = input.payload.screenX - input.windowX;
|
||||
const offsetY = input.payload.screenY - input.windowY;
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { offsetX, offsetY };
|
||||
}
|
||||
|
||||
export function resolveWindowMovePosition(input: {
|
||||
payload: WindowMovePayload;
|
||||
state: WindowMoveState;
|
||||
}): WindowPosition | null {
|
||||
const x = Math.round(input.payload.screenX - input.state.offsetX);
|
||||
const y = Math.round(input.payload.screenY - input.state.offsetY);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
|
||||
import {
|
||||
applyWindowControlsOverlayUpdate,
|
||||
createWindowControlsOverlayState,
|
||||
getMainWindowChromeOptions,
|
||||
getTitleBarOverlayOptions,
|
||||
readBadgeCount,
|
||||
readWindowControlsOverlayUpdate,
|
||||
readWindowTheme,
|
||||
resolveRuntimeTitleBarOverlayOptions,
|
||||
} from "./window-manager";
|
||||
|
||||
describe("window-manager", () => {
|
||||
describe("readBadgeCount", () => {
|
||||
@@ -39,7 +48,7 @@ describe("window-manager", () => {
|
||||
expect(getTitleBarOverlayOptions("light")).toEqual({
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 48,
|
||||
height: 29,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +56,133 @@ describe("window-manager", () => {
|
||||
expect(getTitleBarOverlayOptions("dark")).toEqual({
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 29,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWindowControlsOverlayUpdate", () => {
|
||||
it("accepts partial runtime overlay updates", () => {
|
||||
expect(
|
||||
readWindowControlsOverlayUpdate({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
}),
|
||||
).toEqual({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty and invalid payloads", () => {
|
||||
expect(readWindowControlsOverlayUpdate(undefined)).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({})).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntimeTitleBarOverlayOptions", () => {
|
||||
it("applies the VS Code height minus border adjustment", () => {
|
||||
expect(
|
||||
resolveRuntimeTitleBarOverlayOptions({
|
||||
height: 48,
|
||||
backgroundColor: "#ffffff",
|
||||
foregroundColor: "#09090b",
|
||||
}),
|
||||
).toEqual({
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 47,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWindowControlsOverlayUpdate", () => {
|
||||
it("merges cached colors with later runtime height updates", () => {
|
||||
const setTitleBarOverlay = vi.fn();
|
||||
let state = createWindowControlsOverlayState("dark");
|
||||
|
||||
state = applyWindowControlsOverlayUpdate({
|
||||
win: { setTitleBarOverlay },
|
||||
current: state,
|
||||
update: {
|
||||
backgroundColor: "#18181c",
|
||||
foregroundColor: "#e4e4e7",
|
||||
},
|
||||
});
|
||||
|
||||
state = applyWindowControlsOverlayUpdate({
|
||||
win: { setTitleBarOverlay },
|
||||
current: state,
|
||||
update: { height: 48 },
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
foregroundColor: "#e4e4e7",
|
||||
});
|
||||
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(1, {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 28,
|
||||
});
|
||||
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(2, {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 47,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMainWindowChromeOptions", () => {
|
||||
it("uses frameless hidden title bars with overlay on windows", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "win32",
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarOverlay: {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 29,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses frameless hidden title bars with overlay on linux", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "linux",
|
||||
theme: "light",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarOverlay: {
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 29,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mac traffic-light path separate", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "darwin",
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 14 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
|
||||
import {
|
||||
createWindowMoveState,
|
||||
readWindowMovePayload,
|
||||
resolveWindowMovePosition,
|
||||
type WindowMoveState,
|
||||
} from "./window-drag-coordinates.js";
|
||||
|
||||
export function readBadgeCount(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
||||
@@ -15,6 +9,17 @@ export function readBadgeCount(input: unknown): number {
|
||||
}
|
||||
|
||||
export type WindowTheme = "light" | "dark";
|
||||
export type WindowControlsOverlayUpdate = {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
};
|
||||
|
||||
export type WindowControlsOverlayState = {
|
||||
height: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
};
|
||||
|
||||
export function readWindowTheme(input: unknown): WindowTheme | null {
|
||||
if (input === "light" || input === "dark") {
|
||||
@@ -28,71 +33,111 @@ export function resolveSystemWindowTheme(): WindowTheme {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState {
|
||||
const overlay = getTitleBarOverlayOptions(theme);
|
||||
return {
|
||||
height: overlay.height ?? 29,
|
||||
backgroundColor: overlay.color,
|
||||
foregroundColor: overlay.symbolColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
|
||||
if (theme === "dark") {
|
||||
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
|
||||
return { color: "#18181c", symbolColor: "#e4e4e7", height: 29 };
|
||||
}
|
||||
|
||||
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
|
||||
return { color: "#ffffff", symbolColor: "#09090b", height: 29 };
|
||||
}
|
||||
|
||||
export function getMainWindowChromeOptions(input: {
|
||||
platform: NodeJS.Platform;
|
||||
theme: WindowTheme;
|
||||
}): Pick<
|
||||
Electron.BrowserWindowConstructorOptions,
|
||||
"titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar"
|
||||
> {
|
||||
if (input.platform === "darwin") {
|
||||
return {
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 14 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
titleBarOverlay: getTitleBarOverlayOptions(input.theme),
|
||||
autoHideMenuBar: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readFiniteOverlayHeight(input: unknown): number | null {
|
||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(input);
|
||||
return rounded >= 1 ? rounded : null;
|
||||
}
|
||||
|
||||
function readOverlayColor(input: unknown): string | null {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = input as Record<string, unknown>;
|
||||
const height = readFiniteOverlayHeight(candidate.height);
|
||||
const backgroundColor = readOverlayColor(candidate.backgroundColor);
|
||||
const foregroundColor = readOverlayColor(candidate.foregroundColor);
|
||||
|
||||
if (height === null && backgroundColor === null && foregroundColor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(height !== null ? { height } : {}),
|
||||
...(backgroundColor !== null ? { backgroundColor } : {}),
|
||||
...(foregroundColor !== null ? { foregroundColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeTitleBarOverlayOptions(
|
||||
state: WindowControlsOverlayState,
|
||||
): Electron.TitleBarOverlayOptions {
|
||||
return {
|
||||
color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor,
|
||||
symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor,
|
||||
height: Math.max(0, state.height - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWindowControlsOverlayUpdate(input: {
|
||||
win: Pick<BrowserWindow, "setTitleBarOverlay">;
|
||||
current: WindowControlsOverlayState;
|
||||
update: WindowControlsOverlayUpdate;
|
||||
}): WindowControlsOverlayState {
|
||||
const next: WindowControlsOverlayState = {
|
||||
height: input.update.height ?? input.current.height,
|
||||
backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor,
|
||||
foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor,
|
||||
};
|
||||
|
||||
input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWindowManager(): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
|
||||
// State is keyed per window id to support multiple windows.
|
||||
// ---------------------------------------------------------------------------
|
||||
const moveStates = new Map<number, WindowMoveState>();
|
||||
|
||||
ipcMain.on("paseo:window:startMove", (event, payload: { screenX: number; screenY: number }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const movePayload = readWindowMovePayload(payload);
|
||||
if (!movePayload) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
const [winX, winY] = win.getPosition();
|
||||
const moveState = createWindowMoveState({
|
||||
payload: movePayload,
|
||||
windowX: winX,
|
||||
windowY: winY,
|
||||
});
|
||||
if (!moveState) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
moveStates.set(win.id, moveState);
|
||||
});
|
||||
|
||||
ipcMain.on("paseo:window:moving", (event, payload: { screenX: number; screenY: number }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const state = moveStates.get(win.id);
|
||||
if (!state) return;
|
||||
const movePayload = readWindowMovePayload(payload);
|
||||
if (!movePayload) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
const nextPosition = resolveWindowMovePosition({
|
||||
payload: movePayload,
|
||||
state,
|
||||
});
|
||||
if (!nextPosition) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
win.setPosition(nextPosition.x, nextPosition.y);
|
||||
});
|
||||
|
||||
ipcMain.on("paseo:window:endMove", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) moveStates.delete(win.id);
|
||||
});
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
win.on("closed", () => moveStates.delete(win.id));
|
||||
});
|
||||
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
|
||||
|
||||
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
@@ -124,18 +169,25 @@ export function registerWindowManager(): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
|
||||
ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win || process.platform === "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTheme = readWindowTheme(theme);
|
||||
if (!nextTheme) {
|
||||
const nextUpdate = readWindowControlsOverlayUpdate(update);
|
||||
if (!nextUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
|
||||
const current =
|
||||
overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme());
|
||||
const nextState = applyWindowControlsOverlayUpdate({
|
||||
win,
|
||||
current,
|
||||
update: nextUpdate,
|
||||
});
|
||||
overlayStateByWindow.set(win, nextState);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user