* fix(windows): restore frameless rounded corners and tray transparency (#2505) Windows frameless windows used a solid host backgroundColor under DWM rounding, which showed as a dark rim, while the tray popup kept the default opaque backdrop under CSS border-radius (square tips / 冒尖). Use a clear transparent host with native roundedCorners for app windows, and disable OS rounding for the CSS-shaped tray overlay. Keep html/body transparent on the tray route so corner pixels stay see-through. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(windows): require chrome helpers outside with(ctx) createMainWindowApi injects require from windowManager.cjs, so a relative require inside with(ctx) resolved to electron/bridges/windowsWindowChrome.cjs and crashed every main-window create (MODULE_NOT_FOUND). Load the helpers at module scope in each factory so paths stay file-local. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(windows): keep resizable app windows opaque Codex P2: Electron documents transparent BrowserWindows as not resizable, and enabling resizable can break them. App content windows now only set roundedCorners: true with a solid theme backdrop; the tray overlay stays transparent because it is already non-resizable. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(tray): hide opaque splash on tray overlay route Codex P2: the shared splash screen still painted an opaque full-window fill when the tray panel showed before React mounted, recreating the square-corner flash. Mark tray-window early, CSS-hide the splash, and remove the splash node on DOM ready / tray render. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(tray): defer first show until ready; keep chrome comments ASCII Wait to show the tray BrowserWindow until did-finish-load so the shared opaque splash cannot flash as a square underlay. Also rewrite the new helper file header in ASCII-only English per AGENTS.md. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(tray): cancel pending first-load show on second toggle If the user toggles the tray again while the first renderer load is still pending, clear trayPanelShowWhenReady so did-finish-load cannot open the panel after an intentional dismiss. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(tray): keep shared splash transparent by default Move the opaque splash fill behind html:not(.tray-window) so the tray overlay route never inherits a solid full-window background from the shared splash markup. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
This commit is contained in:
9 files changed
+280
-6
No files matched your search
@@ -36,7 +36,9 @@ let trayMenuData = {
|
||||
};
|
||||
|
||||
let trayPanelWindow = null;
|
||||
|
||||
/** True after the tray panel renderer finishes its first load. */
|
||||
let trayPanelReady = false;
|
||||
let trayPanelShowWhenReady = false;
|
||||
let trayPanelRefreshTimer = null;
|
||||
// Watchdog: if `leave-full-screen` never arrives (edge case / stuck transition)
|
||||
// we eventually give up and force a hide attempt. Better a visible window than
|
||||
@@ -269,6 +271,13 @@ function ensureTrayPanelWindow() {
|
||||
const { BrowserWindow } = electronModule;
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) return trayPanelWindow;
|
||||
|
||||
const {
|
||||
windowsCssRoundedOverlayChromeOptions,
|
||||
} = require("./windowManager/windowsWindowChrome.cjs");
|
||||
|
||||
trayPanelReady = false;
|
||||
trayPanelShowWhenReady = false;
|
||||
|
||||
trayPanelWindow = new BrowserWindow({
|
||||
width: 360,
|
||||
height: 520,
|
||||
@@ -281,8 +290,11 @@ function ensureTrayPanelWindow() {
|
||||
maximizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
hasShadow: true,
|
||||
// Transparent host + clear backdrop so CSS rounded-lg corners are truly
|
||||
// see-through. On Windows, disable OS rounding so it does not stack under
|
||||
// the CSS radius (#2505 / Electron #46468).
|
||||
...windowsCssRoundedOverlayChromeOptions(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload.cjs"),
|
||||
contextIsolation: true,
|
||||
@@ -309,7 +321,17 @@ function ensureTrayPanelWindow() {
|
||||
void trayPanelWindow.loadURL(url);
|
||||
|
||||
trayPanelWindow.webContents.on("did-finish-load", () => {
|
||||
trayPanelReady = true;
|
||||
pushTrayMenuDataToPanel();
|
||||
if (trayPanelShowWhenReady && trayPanelWindow && !trayPanelWindow.isDestroyed()) {
|
||||
trayPanelShowWhenReady = false;
|
||||
try {
|
||||
trayPanelWindow.show();
|
||||
trayPanelWindow.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return trayPanelWindow;
|
||||
@@ -332,8 +354,14 @@ function showTrayPanel() {
|
||||
const y = Math.min(trayBounds.y + trayBounds.height + 6, workArea.y + workArea.height - panelBounds.height);
|
||||
|
||||
win.setBounds({ x, y, width: panelBounds.width, height: panelBounds.height }, false);
|
||||
win.show();
|
||||
win.focus();
|
||||
// Wait for first paint/load so the opaque main-app splash cannot flash as a
|
||||
// square underlay before the tray route clears it (#2505).
|
||||
if (!trayPanelReady) {
|
||||
trayPanelShowWhenReady = true;
|
||||
} else {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
pushTrayMenuDataToPanel();
|
||||
|
||||
@@ -349,6 +377,7 @@ function showTrayPanel() {
|
||||
}
|
||||
|
||||
function hideTrayPanel() {
|
||||
trayPanelShowWhenReady = false;
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
|
||||
trayPanelWindow.hide();
|
||||
}
|
||||
@@ -360,7 +389,12 @@ function hideTrayPanel() {
|
||||
}
|
||||
|
||||
function toggleTrayPanel() {
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed() && trayPanelWindow.isVisible()) {
|
||||
// A pending first-load show counts as "open" so a second click cancels it
|
||||
// instead of leaving did-finish-load to pop the panel open later.
|
||||
const isOpenOrPending =
|
||||
trayPanelShowWhenReady
|
||||
|| (trayPanelWindow && !trayPanelWindow.isDestroyed() && trayPanelWindow.isVisible());
|
||||
if (isOpenOrPending) {
|
||||
hideTrayPanel();
|
||||
} else {
|
||||
showTrayPanel();
|
||||
@@ -1073,6 +1107,8 @@ function cleanup() {
|
||||
}
|
||||
trayPanelWindow = null;
|
||||
}
|
||||
trayPanelReady = false;
|
||||
trayPanelShowWhenReady = false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* eslint-disable no-undef */
|
||||
const {
|
||||
windowsFramelessContentChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
function createMainWindowApi(ctx) {
|
||||
with (ctx) {
|
||||
async function createWindow(electronModule, options) {
|
||||
@@ -121,6 +125,8 @@ function createMainWindowApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...windowBounds,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
@@ -131,6 +137,7 @@ function createMainWindowApi(ctx) {
|
||||
frame: isMac,
|
||||
titleBarStyle: isMac ? "hiddenInset" : undefined,
|
||||
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
|
||||
...windowsChrome,
|
||||
webPreferences: {
|
||||
preload,
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* eslint-disable no-undef */
|
||||
const {
|
||||
windowsFramelessContentChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
function createSettingsWindowApi(ctx) {
|
||||
// The extracted window helpers intentionally share the parent window-manager state.
|
||||
with (ctx) {
|
||||
@@ -125,6 +129,8 @@ function createSettingsWindowApi(ctx) {
|
||||
settingsHeight,
|
||||
});
|
||||
|
||||
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: "netcatty Settings",
|
||||
width: settingsWidth,
|
||||
@@ -144,6 +150,7 @@ function createSettingsWindowApi(ctx) {
|
||||
frame: isMac,
|
||||
titleBarStyle: isMac ? "hiddenInset" : undefined,
|
||||
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
|
||||
...windowsChrome,
|
||||
webPreferences: {
|
||||
preload,
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -9,6 +9,9 @@ const {
|
||||
releaseAttachPopupAuthorization,
|
||||
restoreAttachedSessionOutput,
|
||||
} = require("../terminalAttachRestore.cjs");
|
||||
const {
|
||||
windowsFramelessContentChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
const ATTACH_CLOSE_PREPARE_TIMEOUT_MS = 2000;
|
||||
|
||||
@@ -73,6 +76,8 @@ function createTerminalPopupWindowApi(ctx) {
|
||||
popupY,
|
||||
});
|
||||
|
||||
const windowsChrome = windowsFramelessContentChromeOptions();
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title,
|
||||
width: popupWidth,
|
||||
@@ -85,6 +90,7 @@ function createTerminalPopupWindowApi(ctx) {
|
||||
show: false,
|
||||
frame: false,
|
||||
...(isMac ? { trafficLightPosition: { x: 12, y: 12 } } : {}),
|
||||
...windowsChrome,
|
||||
webPreferences: {
|
||||
preload,
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Windows frameless window chrome helpers.
|
||||
*
|
||||
* Background (#2505):
|
||||
* - Frameless Win11 windows need an explicit `roundedCorners: true` so DWM
|
||||
* applies the system round clip (Electron 34+).
|
||||
* - Transparent tray popups that keep the default opaque white backdrop + CSS
|
||||
* `border-radius` produce square tips under a rounded panel.
|
||||
*
|
||||
* Approach:
|
||||
* - App content windows (resizable): solid host + native `roundedCorners`.
|
||||
* Do NOT set `transparent: true` here - Electron documents transparent
|
||||
* windows as not resizable, and `resizable: true` can break them
|
||||
* (https://www.electronjs.org/docs/latest/tutorial/custom-window-styles).
|
||||
* - Tray / CSS-shaped popovers (`resizable: false`): transparent host + clear
|
||||
* backdrop + `roundedCorners: false` so only the CSS radius defines the
|
||||
* silhouette (Electron #46468: Win11 otherwise forces OS rounding on
|
||||
* transparent windows).
|
||||
*/
|
||||
|
||||
const CLEAR_BACKGROUND = "#00000000";
|
||||
|
||||
function isWindowsPlatform(platform = process.platform) {
|
||||
return platform === "win32";
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for full-bleed app windows (main / settings / terminal popup).
|
||||
* Safe to spread on non-Windows - returns an empty object.
|
||||
*/
|
||||
function windowsFramelessContentChromeOptions(platform = process.platform) {
|
||||
if (!isWindowsPlatform(platform)) return {};
|
||||
return {
|
||||
roundedCorners: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for CSS-rounded overlay windows (tray panel).
|
||||
* Always apply when creating those windows; callers already opt into transparency.
|
||||
*/
|
||||
function windowsCssRoundedOverlayChromeOptions(platform = process.platform) {
|
||||
return {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
// Overlay shape comes from CSS; keep OS rounding off on Win11.
|
||||
...(isWindowsPlatform(platform) ? { roundedCorners: false } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLEAR_BACKGROUND,
|
||||
isWindowsPlatform,
|
||||
windowsFramelessContentChromeOptions,
|
||||
windowsCssRoundedOverlayChromeOptions,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
const {
|
||||
CLEAR_BACKGROUND,
|
||||
isWindowsPlatform,
|
||||
windowsFramelessContentChromeOptions,
|
||||
windowsCssRoundedOverlayChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
test("CLEAR_BACKGROUND is fully transparent ARGB", () => {
|
||||
assert.equal(CLEAR_BACKGROUND, "#00000000");
|
||||
});
|
||||
|
||||
test("isWindowsPlatform detects win32 only", () => {
|
||||
assert.equal(isWindowsPlatform("win32"), true);
|
||||
assert.equal(isWindowsPlatform("darwin"), false);
|
||||
assert.equal(isWindowsPlatform("linux"), false);
|
||||
});
|
||||
|
||||
test("content chrome is a no-op outside Windows", () => {
|
||||
assert.deepEqual(windowsFramelessContentChromeOptions("darwin"), {});
|
||||
assert.deepEqual(windowsFramelessContentChromeOptions("linux"), {});
|
||||
});
|
||||
|
||||
test("content chrome enables native rounding without transparency on Windows", () => {
|
||||
assert.deepEqual(windowsFramelessContentChromeOptions("win32"), {
|
||||
roundedCorners: true,
|
||||
});
|
||||
assert.equal(
|
||||
Object.hasOwn(windowsFramelessContentChromeOptions("win32"), "transparent"),
|
||||
false,
|
||||
"resizable app windows must not use transparent hosts",
|
||||
);
|
||||
});
|
||||
|
||||
test("CSS overlay chrome clears the opaque backdrop on every platform", () => {
|
||||
assert.deepEqual(windowsCssRoundedOverlayChromeOptions("darwin"), {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
});
|
||||
assert.deepEqual(windowsCssRoundedOverlayChromeOptions("linux"), {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
});
|
||||
});
|
||||
|
||||
test("CSS overlay chrome disables OS rounding on Windows", () => {
|
||||
assert.deepEqual(windowsCssRoundedOverlayChromeOptions("win32"), {
|
||||
transparent: true,
|
||||
backgroundColor: CLEAR_BACKGROUND,
|
||||
roundedCorners: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("main/settings/tray call sites wire Windows chrome helpers", () => {
|
||||
const here = __dirname;
|
||||
const main = readFileSync(path.join(here, "mainWindow.cjs"), "utf8");
|
||||
const settings = readFileSync(path.join(here, "settingsWindow.cjs"), "utf8");
|
||||
const popup = readFileSync(path.join(here, "terminalPopupWindow.cjs"), "utf8");
|
||||
const tray = readFileSync(path.join(here, "../globalShortcutBridge.cjs"), "utf8");
|
||||
const css = readFileSync(path.join(here, "../../../index.css"), "utf8");
|
||||
const html = readFileSync(path.join(here, "../../../index.html"), "utf8");
|
||||
const helper = readFileSync(path.join(here, "windowsWindowChrome.cjs"), "utf8");
|
||||
|
||||
for (const [label, source] of [
|
||||
["mainWindow", main],
|
||||
["settingsWindow", settings],
|
||||
["terminalPopupWindow", popup],
|
||||
]) {
|
||||
assert.match(source, /require\("\.\/windowsWindowChrome\.cjs"\)/, `${label} must require chrome helpers`);
|
||||
assert.match(source, /windowsFramelessContentChromeOptions/, `${label} must use content chrome helper`);
|
||||
assert.doesNotMatch(source, /resolveFramelessHostBackgroundColor/, `${label} should not clear host backdrop`);
|
||||
const requireIndex = source.indexOf('require("./windowsWindowChrome.cjs")');
|
||||
const withIndex = source.indexOf("with (ctx)");
|
||||
assert.ok(
|
||||
requireIndex !== -1 && withIndex !== -1 && requireIndex < withIndex,
|
||||
`${label}: require chrome helpers before with(ctx) so injected require cannot remount the path`,
|
||||
);
|
||||
}
|
||||
assert.match(tray, /windowsCssRoundedOverlayChromeOptions/);
|
||||
assert.match(tray, /#2505/);
|
||||
assert.match(css, /html\.tray-window/);
|
||||
assert.match(css, /html\.tray-window \.splash-screen/);
|
||||
assert.match(html, /tray-window/);
|
||||
assert.match(html, /removeTraySplash|splash\.remove\(\)/);
|
||||
assert.match(tray, /trayPanelShowWhenReady|trayPanelReady/);
|
||||
assert.match(tray, /isOpenOrPending/);
|
||||
assert.match(helper, /square tips under a rounded panel/);
|
||||
assert.match(helper, /not resizable/);
|
||||
assert.doesNotMatch(helper, /[^\x00-\x7F]/, "helper comments must stay ASCII-only");
|
||||
const contentHelperMatch = helper.match(
|
||||
/function windowsFramelessContentChromeOptions\([\s\S]*?\n\}/,
|
||||
);
|
||||
assert.ok(contentHelperMatch, "content chrome helper must exist");
|
||||
assert.match(contentHelperMatch[0], /roundedCorners:\s*true/);
|
||||
assert.doesNotMatch(
|
||||
contentHelperMatch[0],
|
||||
/transparent/,
|
||||
"content chrome must stay opaque/resizable",
|
||||
);
|
||||
});
|
||||
@@ -401,6 +401,19 @@ body {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Tray overlay window: opaque body would show as square tips outside CSS radius (#2505). */
|
||||
html.tray-window,
|
||||
html.tray-window body,
|
||||
html.tray-window #root {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.tray-window .splash-screen {
|
||||
display: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 220ms;
|
||||
|
||||
+35
-1
@@ -71,6 +71,19 @@
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Tray panel: keep the host fully clear so CSS rounded corners are see-through. */
|
||||
html.tray-window,
|
||||
html.tray-window body,
|
||||
html.tray-window #root {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.tray-window .splash-screen {
|
||||
display: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Windows flag/emoji fallbacks live on --font-sans (not a body font-family
|
||||
override) so Appearance UI font still reaches portaled panels (#2647). */
|
||||
html.platform-win32 {
|
||||
@@ -120,11 +133,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--background));
|
||||
background: transparent;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Main app only: opaque splash fill. Tray overlay stays clear (#2505). */
|
||||
html:not(.tray-window) .splash-screen {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.splash-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -196,6 +214,22 @@
|
||||
if (/Win/i.test(ua)) {
|
||||
root.classList.add('platform-win32');
|
||||
}
|
||||
|
||||
// Tray overlay uses CSS border-radius over a transparent BrowserWindow.
|
||||
// Mark it before first paint so body/root/splash never flash an opaque
|
||||
// backdrop (#2505). Splash lives in <body>, so remove it on DOM ready.
|
||||
if ((window.location.hash || '') === '#/tray' || (window.location.hash || '').indexOf('#/tray') === 0) {
|
||||
root.classList.add('tray-window');
|
||||
var removeTraySplash = function () {
|
||||
var splash = document.getElementById('splash');
|
||||
if (splash) splash.remove();
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', removeTraySplash, { once: true });
|
||||
} else {
|
||||
removeTraySplash();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -140,8 +140,19 @@ const getRoute = () => {
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
||||
const syncTrayWindowClass = (route: string) => {
|
||||
const rootEl = document.documentElement;
|
||||
if (route === 'tray') {
|
||||
rootEl.classList.add('tray-window');
|
||||
document.getElementById('splash')?.remove();
|
||||
} else {
|
||||
rootEl.classList.remove('tray-window');
|
||||
}
|
||||
};
|
||||
|
||||
const renderApp = () => {
|
||||
const route = getRoute();
|
||||
syncTrayWindowClass(route);
|
||||
if (route === 'settings') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
|
||||
Reference in new issue
Block a user