综合更新

This commit is contained in:
cc committed 2026-07-06 18:09:09 +08:00
1 parent d733c21145
commit 147f3b7045
57 files changed
+3038 -800

No files matched your search

+302 -65
View File
@@ -1,14 +1,32 @@
import { BrowserWindow, ipcMain, screen } from "electron";
import { BrowserWindow, desktopCapturer, ipcMain, screen } from "electron";
import { join } from "path";
import { ConfigService } from "../services/config";
// Linux D-Bus通知服务
// 通知交付分流:Windows 走特制液态玻璃通知窗口;
// Linux / macOS 走系统通知中心(systemNotificationService)
const isLinux = process.platform === "linux";
let linuxNotificationService:
| typeof import("../services/linuxNotificationService")
const isMac = process.platform === "darwin";
const usesSystemNotifications = isLinux || isMac;
// 原生液态玻璃(Windows 专用):DXGI 零拷贝采集 + D3D11 玻璃管线 + DComp 直接上屏,
// 感知滞后中位 ~6ms(Chromium 流方案 ~77ms),渲染完全不经过 Electron 进程。
// 不可用(系统过旧 / 二进制缺失)时自动回退 Chromium 桌面流管线
type NativeGlassModule = typeof import("@hicccc77/electron-liquid-glass");
let nativeGlass: NativeGlassModule | null = null;
if (!usesSystemNotifications) {
try {
const mod: NativeGlassModule = require("@hicccc77/electron-liquid-glass");
nativeGlass = mod.isSupported() ? mod : null;
} catch {
nativeGlass = null;
}
}
let systemNotificationService:
| typeof import("../services/systemNotificationService")
| null = null;
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
// 用于处理通知点击的回调函数(系统通知平台用于导航到会话)
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
export function setNotificationNavigateHandler(
@@ -20,19 +38,119 @@ export function setNotificationNavigateHandler(
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
// 原生玻璃面板:与通知窗口一样常驻复用(创建后隐藏待命),
// 展示期跟随渲染层上报的卡片实测矩形(notification:glassRect)
let glassPanel: import("@hicccc77/electron-liquid-glass").GlassPanel | null = null;
// 面板创建时的 dpr:原生端几何常量按 dpr 换算且仅在创建时设定,
// 显示器缩放变化后必须重建面板,否则复用旧 dpr 会算错折射几何
let glassPanelDpr = 0;
function destroyGlassPanel() {
if (glassPanel) {
try {
glassPanel.destroy();
} catch {
/* 面板已随会话销毁 */
}
glassPanel = null;
}
}
// 视觉参数换算(缺省值与渲染层 GLASS_PARAMS 一致,CSS 值 → 物理像素/比例)
function toGlassParams(payload: Record<string, number | undefined>, scale: number) {
return {
cornerRadius: (payload.cornerRadius ?? 16) * scale,
blurSigma: (payload.blurSigma ?? 2) * scale,
displacementScale: payload.displacementScale ?? 70,
aberrationIntensity: payload.aberrationIntensity ?? 1,
saturation: (payload.saturation ?? 140) / 100,
};
}
// 创建或复用玻璃面板并同步全部状态(不负责 show/hide)。
// 空闲预热与 glassRect 上报共用此路径:预热时占位几何 + 空亮度带,
// 首次上报会以实测值幂等覆盖
function ensureGlassPanel(
bounds: { x: number; y: number; width: number; height: number },
params: ReturnType<typeof toGlassParams>,
scale: number,
bands: Array<{ id: number; x: number; y: number; width: number; height: number }>,
) {
if (!nativeGlass || !notificationWindow || notificationWindow.isDestroyed()) {
return null;
}
if (glassPanel && glassPanelDpr !== scale) destroyGlassPanel();
if (!glassPanel) {
glassPanel = nativeGlass.createPanel({
...bounds,
...params,
dpr: scale,
anchorWindow: notificationWindow,
lumaBands: bands,
onLuma: (bandStats) => {
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.webContents.send("notification:luma", bandStats);
}
},
});
glassPanelDpr = scale;
} else {
glassPanel.setBounds(bounds);
glassPanel.setParams(params);
glassPanel.setLumaBands(bands);
glassPanel.anchor(notificationWindow);
}
return glassPanel;
}
// —— Chromium 桌面流回退管线(仅原生玻璃不可用时启用:系统过旧 / 二进制缺失)——
// 透明窗口内 backdrop-filter 采样不到桌面像素(Chromium 只能采样页面内容),
// 回退路径下折射源由渲染层用 getUserMedia 开启的实时桌面视频流提供。
// 主进程只负责预热并缓存屏幕采集源 ID:desktopCapturer.getSources 会同步
// 初始化 DirectX 采集管线(实测阻塞主线程 300ms+,不要缩略图也一样),
// 绝不能出现在通知弹出路径上,否则每条通知都会让整个应用卡一下。
// 通知窗口自身已设置 content protection,不会被拍进视频流造成折射回环
let cachedSourceId: string | null = null;
let sourceIdRefreshing: Promise<void> | null = null;
function refreshDesktopSourceId(): Promise<void> {
if (sourceIdRefreshing) return sourceIdRefreshing;
sourceIdRefreshing = (async () => {
try {
const display = screen.getPrimaryDisplay();
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: 0, height: 0 },
});
const source =
sources.find((s) => s.display_id === String(display.id)) ?? sources[0];
cachedSourceId = source?.id ?? null;
} catch (error) {
console.warn(
"[NotificationWindow] Failed to refresh desktop source id:",
error,
);
} finally {
sourceIdRefreshing = null;
}
})();
return sourceIdRefreshing;
}
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
lastNotificationData = null;
destroyGlassPanel();
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
if (isLinux && linuxNotificationService) {
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
// Linux/macOS:关闭系统通知服务并清理缓存(fire-and-forget,不阻塞退出)
if (usesSystemNotifications && systemNotificationService) {
systemNotificationService.shutdownSystemNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown system notification service:", error);
});
linuxNotificationService = null;
systemNotificationService = null;
}
if (!notificationWindow || notificationWindow.isDestroyed()) {
@@ -50,6 +168,17 @@ export function destroyNotificationWindow() {
}
}
// 窗口通过 min/max 锁定尺寸,程序化调整尺寸前需要同步放宽限制。
// 尺寸未变化时直接跳过:可见状态下重复 setSize 会让 DWM
// 短暂拉伸旧帧缓冲,在通知周围闪出一圈"幽灵轮廓"
function applyWindowSize(win: BrowserWindow, width: number, height: number) {
const [currentWidth, currentHeight] = win.getSize();
if (currentWidth === width && currentHeight === height) return;
win.setMinimumSize(width, height);
win.setMaximumSize(width, height);
win.setSize(width, height);
}
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow;
@@ -68,9 +197,12 @@ export function createNotificationWindow() {
notificationWindow = new BrowserWindow({
width: width,
height: height,
type: "toolbar", // 有助于在某些操作系统上保持置顶
type: "toolbar", // 辅助置顶(仅 Windows 走此窗口)
frame: false,
// 无边框透明窗口:不会出现 DWM 材质窗口的系统描边,
// 玻璃底下的"桌面"由原生面板或渲染层的实时桌面视频流提供
transparent: true,
hasShadow: false, // 卡片投影由 CSS 提供
resizable: false,
show: false,
alwaysOnTop: true,
@@ -85,6 +217,13 @@ export function createNotificationWindow() {
},
});
// 把窗口从屏幕采集中排除(WDA_EXCLUDEFROMCAPTURE,Win10 2004+):
// 玻璃靠采集屏幕获得折射像素,不排除自身会拍到自己形成无限回环。
// 副作用:系统截图/录屏中看不到通知窗口本体
notificationWindow.setContentProtection(true);
applyWindowSize(notificationWindow, width, height);
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
@@ -99,6 +238,12 @@ export function createNotificationWindow() {
console.log("[NotificationWindow] Loading URL:", loadUrl);
notificationWindow.loadURL(loadUrl);
// Chromium 会按 file:// 域持久化页面缩放(主窗口与通知窗口同域):
// 任何来源的缩放残留都会让通知按错误的逻辑尺寸排版,这里强制钉回 1
notificationWindow.webContents.on("did-finish-load", () => {
notificationWindow?.webContents.setZoomFactor(1);
});
notificationWindow.on("closed", () => {
notificationWindow = null;
});
@@ -139,9 +284,9 @@ export async function showNotification(data: any) {
}
}
// Linux 使用 D-Bus 通知
if (isLinux) {
await showLinuxNotification(data);
// Linux / macOS 走系统通知中心,仅 Windows 使用特制液态玻璃窗口
if (usesSystemNotifications) {
await showViaSystemNotification(data);
return;
}
@@ -162,24 +307,22 @@ export async function showNotification(data: any) {
}
}
// 显示Linux通知
async function showLinuxNotification(data: any) {
if (!linuxNotificationService) {
// 经系统通知中心显示(Linux / macOS)
async function showViaSystemNotification(data: any) {
if (!systemNotificationService) {
try {
linuxNotificationService =
await import("../services/linuxNotificationService");
systemNotificationService =
await import("../services/systemNotificationService");
} catch (error) {
console.error(
"[NotificationWindow] Failed to load Linux notification service:",
"[NotificationWindow] Failed to load system notification service:",
error,
);
return;
}
}
const { showLinuxNotification: showNotification } = linuxNotificationService;
const notificationData = {
systemNotificationService.showSystemNotification({
title: data.title,
content: data.content,
avatarUrl: data.avatarUrl,
@@ -188,21 +331,20 @@ async function showLinuxNotification(data: any) {
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
expireTimeout: 5000,
};
showNotification(notificationData);
});
}
let lastNotificationData: any = null;
async function showAndSend(win: BrowserWindow, data: any) {
lastNotificationData = data;
const config = ConfigService.getInstance();
const position = (await config.get("notificationPosition")) || "top-right";
// 更新位置
const { width: screenWidth, height: screenHeight } =
screen.getPrimaryDisplay().workAreaSize;
// 更新位置:基于工作区完整矩形(含原点偏移)定位。
// macOS 菜单栏、Windows 任务栏靠上/靠左时工作区原点不为 (0,0),
// 只用 workAreaSize 会把通知压进系统栏下面
const display = screen.getPrimaryDisplay();
const workArea = display.workArea;
const winWidth = position === "top-center" ? 280 : 344;
const winHeight = 114;
const padding = 20;
@@ -212,57 +354,81 @@ async function showAndSend(win: BrowserWindow, data: any) {
switch (position) {
case "top-center":
x = (screenWidth - winWidth) / 2;
y = padding;
x = workArea.x + (workArea.width - winWidth) / 2;
y = workArea.y + padding;
break;
case "top-right":
x = screenWidth - winWidth - padding;
y = padding;
x = workArea.x + workArea.width - winWidth - padding;
y = workArea.y + padding;
break;
case "bottom-right":
x = screenWidth - winWidth - padding;
y = screenHeight - winHeight - padding;
x = workArea.x + workArea.width - winWidth - padding;
y = workArea.y + workArea.height - winHeight - padding;
break;
case "top-left":
x = padding;
y = padding;
x = workArea.x + padding;
y = workArea.y + padding;
break;
case "bottom-left":
x = padding;
y = screenHeight - winHeight - padding;
x = workArea.x + padding;
y = workArea.y + workArea.height - winHeight - padding;
break;
}
win.setPosition(Math.floor(x), Math.floor(y));
win.setSize(winWidth, winHeight); // 确保尺寸
const winX = Math.floor(x);
const winY = Math.floor(y);
// 采集源 ID 使用启动时预热的缓存;仅当首条通知早于预热完成时补一次。
// 除此之外通知路径不触碰 desktopCapturer,弹出过程主线程零阻塞。
// 原生玻璃可用时折射由原生面板提供,渲染层不再开启 Chromium 桌面流
if (!nativeGlass && !cachedSourceId) await refreshDesktopSourceId();
const payload = {
...data,
position,
backdrop: {
native: Boolean(nativeGlass),
sourceId: nativeGlass ? null : cachedSourceId,
winX,
winY,
width: display.size.width,
height: display.size.height,
},
};
lastNotificationData = payload;
win.setPosition(winX, winY);
// 窗口高度始终沿用渲染层的实测校准值(notification:resize),
// 这里只同步宽度;反复重置高度会造成 114→实测高度的弹跳闪烁
const [, currentHeight] = win.getSize();
applyWindowSize(win, winWidth, currentHeight);
win.webContents.send("notification:show", payload);
// 设为可交互
win.setIgnoreMouseEvents(false);
win.showInactive(); // 显示但不聚焦
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
win.webContents.send("notification:show", { ...data, position });
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
}
// 注册通知处理
export async function registerNotificationHandlers() {
// Linux: 初始化D-Bus服务
if (isLinux) {
// Linux / macOS:初始化系统通知服务
if (usesSystemNotifications) {
try {
const linuxNotificationModule =
await import("../services/linuxNotificationService");
linuxNotificationService = linuxNotificationModule;
const systemNotificationModule =
await import("../services/systemNotificationService");
systemNotificationService = systemNotificationModule;
// 初始化服务
await linuxNotificationModule.initLinuxNotificationService();
await systemNotificationModule.initSystemNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((payload: unknown) => {
// 注册通知点击回调(导航到会话/洞察)
systemNotificationModule.onNotificationAction((payload: unknown) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
"[NotificationWindow] System notification clicked, payload:",
payload,
);
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
@@ -278,11 +444,11 @@ export async function registerNotificationHandlers() {
});
console.log(
"[NotificationWindow] Linux notification service initialized",
"[NotificationWindow] System notification service initialized",
);
} catch (error) {
console.error(
"[NotificationWindow] Failed to initialize Linux notification service:",
"[NotificationWindow] Failed to initialize system notification service:",
error,
);
}
@@ -293,20 +459,60 @@ export async function registerNotificationHandlers() {
});
ipcMain.handle("notification:close", () => {
if (isLinux && linuxNotificationService) {
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
if (usesSystemNotifications) {
// 系统通知由通知中心管理生命周期,无窗口可关
return;
}
// 窗口即将隐藏,玻璃面板立即消失(渲染层通常已提前发过淡出信号)
glassPanel?.hide(0);
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
});
// —— 原生玻璃面板生命周期(仅 nativeGlass 可用时渲染层才会发这些消息)——
// 渲染层在卡片入场动画落定后上报实测几何(窗口本地 CSS 像素)
ipcMain.on("notification:glassRect", (_event, payload) => {
if (!nativeGlass || !notificationWindow || notificationWindow.isDestroyed()) return;
const display = screen.getDisplayMatching(notificationWindow.getBounds());
// 优先用渲染层实测的 devicePixelRatio(已含可能的页面缩放),显示器缩放兜底
const scale = payload.dpr || display.scaleFactor;
const [winX, winY] = notificationWindow.getPosition();
const toPhysical = (rect: { x: number; y: number; width: number; height: number }) => ({
x: Math.round(rect.x * scale),
y: Math.round(rect.y * scale),
width: Math.round(rect.width * scale),
height: Math.round(rect.height * scale),
});
const bounds = {
x: Math.round((winX + payload.card.x) * scale),
y: Math.round((winY + payload.card.y) * scale),
width: Math.round(payload.card.width * scale),
height: Math.round(payload.card.height * scale),
};
// 亮度带矩形是卡片本地坐标(带 id:0=整卡 1=标题行 2=正文)
const bands = (payload.bands ?? []).map(
(band: { id: number; x: number; y: number; width: number; height: number }) => ({
id: band.id,
...toPhysical(band),
}),
);
const panel = ensureGlassPanel(bounds, toGlassParams(payload, scale), scale, bands);
panel?.show(120);
});
// 渲染层退场动画开始:面板与卡片的 0.3s 渐隐同步淡出
ipcMain.on("notification:glassHide", () => {
glassPanel?.hide(240);
});
// Handle renderer ready event (fix race condition)
ipcMain.on("notification:ready", (event) => {
if (isLinux) {
// Linux不需要通知窗口,拦截通知窗口渲染
if (usesSystemNotifications) {
// 系统通知平台不需要通知窗口,拦截通知窗口渲染
return;
}
console.log("[NotificationWindow] Renderer ready, checking cached data");
@@ -323,10 +529,44 @@ export async function registerNotificationHandlers() {
}
});
// 启动空闲期预热(系统通知平台不需要):
// - 采集源 ID:getSources 初始化采集管线会同步阻塞主线程 300ms+,放到空闲期一次性完成
// (原生玻璃可用时不需要 Chromium 采集源,跳过)
// - 预创建通知窗口:首条通知免去窗口创建和渲染器冷启动
// - 预创建原生玻璃面板(隐藏待命):worker 线程、D3D 设备、着色器编译、
// DComp 窗口链全部离开首条通知的可见路径(原生端实测 ~150ms);
// 隐藏面板零渲染零采集,常驻开销可忽略
if (!usesSystemNotifications) {
setTimeout(() => {
if (!nativeGlass) refreshDesktopSourceId();
const win = createNotificationWindow();
if (nativeGlass && win) {
const scale = screen.getPrimaryDisplay().scaleFactor;
const [winX, winY] = win.getPosition();
ensureGlassPanel(
{
x: Math.round(winX * scale),
y: Math.round(winY * scale),
width: Math.round(344 * scale),
height: Math.round(96 * scale),
},
toGlassParams({}, scale),
scale,
[],
);
}
}, 3000);
if (!nativeGlass) {
// 显示器增删后源 ID 可能失效,重新预热
screen.on("display-added", () => refreshDesktopSourceId());
screen.on("display-removed", () => refreshDesktopSourceId());
}
}
// Handle resize request from renderer
ipcMain.on("notification:resize", (event, { width, height }) => {
if (isLinux) {
// Linux 通知通过D-Bus自动调整大小
if (usesSystemNotifications) {
// 系统通知由通知中心排版,无需窗口尺寸同步
return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
@@ -342,11 +582,8 @@ export async function registerNotificationHandlers() {
// We can re-call setPosition or just let it be.
// If bottom-right, y needs to prevent overflow.
// Ideally we get current config position
const bounds = notificationWindow.getBounds();
// Check if we need to adjust Y?
// For now, let's just set the size as requested.
notificationWindow.setSize(Math.round(width), Math.round(height));
applyWindowSize(notificationWindow, Math.round(width), Math.round(height));
}
});