fix(windows): 恢复无边框窗口圆角并修复托盘四角透明 (#2505) (#2767)

* 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:
authored and GitHub committed 2026-08-06 11:50:32 +08:00
1 parent 61839acaca
commit a8bd5e0f16
9 files changed
+278 -4

No files matched your search

+39 -3
View File
@@ -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);
// 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",
);
});
+13
View File
@@ -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
View File
@@ -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
}
+11
View File
@@ -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>