综合更新

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

+59 -97
View File
@@ -39,6 +39,12 @@ import { bizService } from './services/bizService'
import { backupService } from './services/backupService'
import { imageDownloadService } from './services/imageDownloadService'
// 屏幕采集去节流(仅影响通知玻璃的 Chromium 流回退管线;Windows 主路径为
// 原生面板渲染,不经过 Chromium 采集):默认桌面采集 CPU 预算限制在 50%,
// 会自适应压低采集帧率、显著增加回退路径下折射的跟随延迟。
// 回退采集只在通知展示的数秒内运行且分辨率已降为逻辑尺寸,放开预算不影响常态性能
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100')
// 配置自动更新
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
@@ -1268,12 +1274,14 @@ function createSplashWindow(): BrowserWindow {
const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL)
splashUrl.searchParams.set('themeId', splashThemeId)
splashUrl.searchParams.set('themeMode', splashThemeMode)
splashUrl.searchParams.set('version', app.getVersion())
splashWindow.loadURL(splashUrl.toString())
} else {
splashWindow.loadFile(join(__dirname, '../dist/splash.html'), {
query: {
themeId: splashThemeId,
themeMode: splashThemeMode
themeMode: splashThemeMode,
version: app.getVersion()
}
})
}
@@ -4553,10 +4561,10 @@ app.whenReady().then(async () => {
shouldShowMain = onboardingDone
if (!startInBackground) {
// 非静默模式下显示 Splash,提供启动反馈
// 非静默模式下显示 Splash,提供启动反馈(主题/版本号通过 URL 参数传入)
createSplashWindow()
// 等待 Splash 页面加载完成后再推送进度
// 等待 Splash 页面加载完成,确保后续 executeJavaScript 推送的进度可靠送达
if (splashWindow) {
await new Promise<void>((resolve) => {
if (splashWindow!.webContents.isLoading()) {
@@ -4565,13 +4573,9 @@ app.whenReady().then(async () => {
resolve()
}
})
splashWindow.webContents
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
.catch(() => {})
}
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
return new Promise((resolve) => {
let settled = false
@@ -4597,112 +4601,29 @@ app.whenReady().then(async () => {
})
}
updateSplashProgress(5, '正在加载配置...')
// 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) {
const themeId = configService.get('themeId') || 'cloud-dancer'
const themeMode = configService.get('theme') || 'system'
splashWindow.webContents
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
.catch(() => {})
}
await delay(200)
// 设置资源路径
updateSplashProgress(12, '正在初始化...')
// 基础初始化均为毫秒级操作,直接顺序执行
updateSplashProgress(10, '正在初始化...')
const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData')
await delay(200)
// 初始化数据库服务
updateSplashProgress(20, '正在初始化...')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
await delay(200)
// 注册 IPC 处理器
updateSplashProgress(28, '正在初始化...')
registerIpcHandlers()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json)
insightService.handleDbMonitorChange(type, json)
})
messagePushService.start()
insightService.start()
groupSummaryService.start()
await delay(200)
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
if (onboardingDone) {
updateSplashProgress(34, '正在连接数据库...')
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
if (!connected) {
const reason = connectWarmup.timedOut
? connectWarmup.error
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
updateSplashProgress(68, '数据库预热已跳过')
} else {
const preloadUsernames = new Set<string>()
updateSplashProgress(44, '正在预加载会话...')
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
for (const session of sessionsWarmup.value.sessions) {
const username = String((session as any)?.username || '').trim()
if (username) preloadUsernames.add(username)
}
}
updateSplashProgress(56, '正在预加载联系人...')
const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000)
if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) {
for (const contact of contactsWarmup.value.contacts) {
const username = String((contact as any)?.username || '').trim()
if (username) preloadUsernames.add(username)
}
}
updateSplashProgress(63, '正在缓存联系人头像...')
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
}
updateSplashProgress(68, '正在初始化消息库索引...')
await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000)
updateSplashProgress(69, '正在预加载导出统计...')
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.getExportSessionStats(avatarWarmupUsernames, { includeRelations: false }), 8000)
}
}
} else {
updateSplashProgress(68, '首次启动准备中...')
}
// 创建主窗口(不显示,由启动流程统一控制)
updateSplashProgress(70, '正在准备主窗口...')
// 提前创建主窗口(隐藏),让渲染进程加载与数据库预热并行进行
updateSplashProgress(20, '正在准备主窗口...')
ensureWeChatRequestHeaderInterceptor()
mainWindow = createWindow({ autoShow: false })
const resolvedTrayIcon = resolveAppIconPath()
try {
tray = new Tray(resolvedTrayIcon)
tray.setToolTip('WeFlow')
@@ -4746,8 +4667,38 @@ app.whenReady().then(async () => {
console.warn('[Tray] Failed to create tray icon:', e)
}
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
updateSplashProgress(70, '正在准备主窗口...', true)
// 已完成引导时,与渲染进程加载并行预热会话列表和联系人名字/头像缓存
if (onboardingDone) {
updateSplashProgress(35, '正在连接数据库...')
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
if (!connected) {
const reason = connectWarmup.timedOut
? connectWarmup.error
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
} else {
updateSplashProgress(55, '正在预加载会话...')
const preloadUsernames = new Set<string>()
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
for (const session of sessionsWarmup.value.sessions) {
const username = String((session as any)?.username || '').trim()
if (username) preloadUsernames.add(username)
}
}
updateSplashProgress(75, '正在缓存联系人头像...')
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
}
}
}
// 等待主窗口加载完成(进度条末端呼吸光点)
updateSplashProgress(90, '正在准备主窗口...', true)
await new Promise<void>((resolve) => {
if (mainWindowReady) {
resolve()
@@ -4761,7 +4712,6 @@ app.whenReady().then(async () => {
// 加载完成,收尾
updateSplashProgress(100, '启动完成')
await new Promise((resolve) => setTimeout(resolve, 250))
closeSplash()
if (!onboardingDone) {
@@ -4772,6 +4722,18 @@ app.whenReady().then(async () => {
mainWindow?.show()
}
// 依赖数据库的后台服务在窗口显示后再启动,避免与启动预热争抢数据库 worker
messagePushService.start()
insightService.start()
groupSummaryService.start()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
// 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup()
+10 -1
View File
@@ -21,10 +21,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
// 原生玻璃面板:卡片实测几何上报 / 退场淡出 / 亮度带回调(Windows 原生模式专用)
glassRect: (payload: any) => ipcRenderer.send('notification:glassRect', payload),
glassHide: () => ipcRenderer.send('notification:glassHide'),
onLuma: (callback: (bands: any) => void) => {
const listener = (_: any, bands: any) => callback(bands)
ipcRenderer.on('notification:luma', listener)
return () => ipcRenderer.removeListener('notification:luma', listener)
},
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
},
// 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
onNavigateToSession: (callback: (sessionId: string) => void) => {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
+20 -1
View File
@@ -449,6 +449,7 @@ class ChatService {
private readonly visibilityAnomalyLogBurst = 3
private visibilityAnomalyLogState = new Map<string, { windowStart: number; total: number; suppressed: number }>()
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private connectInFlight: Promise<{ success: boolean; error?: string }> | null = null
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
@@ -561,9 +562,27 @@ class ChatService {
}
/**
* 连接数据库
* 连接数据库(并发调用共享同一次连接过程,避免重复 open)
*/
async connect(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
if (this.connectInFlight) {
return this.connectInFlight
}
const promise = this.connectInternal()
this.connectInFlight = promise
try {
return await promise
} finally {
if (this.connectInFlight === promise) {
this.connectInFlight = null
}
}
}
private async connectInternal(): Promise<{ success: boolean; error?: string }> {
try {
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
@@ -5,34 +5,10 @@ import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickn
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { buildMarkdownBlockquote, escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from '../../export/utils/markdown';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from '../../wcdbService';
const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
export class MarkdownFormatter {
constructor(private exportService: any) {}
@@ -0,0 +1,24 @@
export const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
export const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
export const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
export const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
+5 -5
View File
@@ -1453,9 +1453,9 @@ class HttpService {
}
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
if (!['json', 'html', 'arkmejson'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat === 'md' ? 'markdown' : rawFormat
if (!['json', 'html', 'arkmejson', 'markdown'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson/markdown')
return
}
@@ -1466,7 +1466,7 @@ class HttpService {
const options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1477,7 +1477,7 @@ class HttpService {
endTime?: number
} = {
outputDir,
format: format as 'json' | 'html' | 'arkmejson',
format: format as 'json' | 'html' | 'arkmejson' | 'markdown',
usernames,
keyword,
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
+125 -23
View File
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
import crypto from 'crypto'
import { WasmService } from './wasmService'
import zlib from 'zlib'
import { escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from './export/utils/markdown'
export interface SnsLivePhoto {
url: string
@@ -332,6 +333,30 @@ const decodeXmlText = (text: string): string => {
.replace(/&#39;/gi, "'")
}
const normalizeSnsLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
/** 解析朋友圈位置的展示文本(POI 优先,附加国家/城市信息) */
const resolveSnsLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeSnsLocationText(location.poiName),
normalizeSnsLocationText(location.poiAddressName),
normalizeSnsLocationText(location.label),
normalizeSnsLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeSnsLocationText(location.country),
normalizeSnsLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
@@ -1358,7 +1383,7 @@ class SnsService {
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1735,6 +1760,11 @@ class SnsService {
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'markdown') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.md`)
const markdown = this.generateMarkdown(allPosts, { usernames, keyword })
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, markdown, 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
@@ -1752,6 +1782,99 @@ class SnsService {
}
}
/**
* 生成朋友圈 Markdown 导出文件
*/
private generateMarkdown(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }): string {
const formatTime = (ts: number) => {
const d = new Date(ts * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 逐行转义正文,行尾补两个空格以在渲染时保留换行
const formatMultilineText = (text: string): string => text
.split(/\r?\n/)
.map(line => escapeMarkdownText(line))
.join(' \n')
const lines: string[] = []
lines.push('# 朋友圈导出')
lines.push('')
lines.push(`- 动态数量: ${posts.length}`)
if (filters.keyword) lines.push(`- 筛选关键词: ${escapeMarkdownText(filters.keyword)}`)
if (filters.usernames && filters.usernames.length > 0) lines.push(`- 筛选用户: ${filters.usernames.length} 人`)
lines.push(`- 导出时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push('- 导出工具: WeFlow')
for (const post of posts) {
lines.push('')
lines.push('---')
lines.push('')
lines.push(`## ${escapeMarkdownText(post.nickname)} · ${formatTime(post.createTime)}`)
lines.push('')
if (post.contentDesc) {
lines.push(formatMultilineText(post.contentDesc))
lines.push('')
}
const locationText = resolveSnsLocationText(post.location)
if (locationText) {
lines.push(`📍 ${escapeMarkdownText(locationText)}`)
lines.push('')
}
const mediaLines: string[] = []
post.media.forEach((m, mi) => {
const indexSuffix = post.media.length > 1 ? ` ${mi + 1}` : ''
const localPath = (m as any).localPath as string | undefined
const isVideo = isVideoUrl(m.url)
if (localPath) {
mediaLines.push(isVideo
? `[视频${indexSuffix}](${toMarkdownUrl(localPath)})`
: `![图片${indexSuffix}](${toMarkdownUrl(localPath)})`)
} else if (m.url) {
mediaLines.push(`[${isVideo ? '视频' : '图片'}${indexSuffix}](${toMarkdownUrl(m.url)})`)
}
const livePhotoLocalPath = m.livePhoto ? (m.livePhoto as any).localPath as string | undefined : undefined
if (livePhotoLocalPath) {
mediaLines.push(`[实况视频${indexSuffix}](${toMarkdownUrl(livePhotoLocalPath)})`)
}
})
if (mediaLines.length > 0) {
// 行尾两个空格产生硬换行,让每个媒体渲染时各占一行
lines.push(mediaLines.join(' \n'))
lines.push('')
}
if (post.linkTitle && post.linkUrl) {
lines.push(`🔗 [${escapeMarkdownLinkText(post.linkTitle)}](${toMarkdownUrl(post.linkUrl)})`)
lines.push('')
}
if (post.likes.length > 0) {
lines.push(`♥ ${post.likes.map(l => escapeMarkdownText(l)).join('、')}`)
lines.push('')
}
if (post.comments.length > 0) {
lines.push('💬 评论:')
lines.push('')
for (const c of post.comments) {
const ref = c.refNickname ? ` 回复 **${escapeMarkdownText(c.refNickname)}**` : ''
lines.push(`- **${escapeMarkdownText(c.nickname)}**${ref}:${escapeMarkdownLinkText(c.content)}`)
}
lines.push('')
}
// 去掉每条动态末尾多余的空行,统一由分隔符控制间距
while (lines[lines.length - 1] === '') lines.pop()
}
lines.push('')
return lines.join('\n')
}
/**
* 生成朋友圈 HTML 导出文件
*/
@@ -1778,27 +1901,6 @@ class SnsService {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1822,7 +1924,7 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
: ''
const locationText = resolveLocationText(post.location)
const locationText = resolveSnsLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
@@ -1,7 +1,11 @@
import { Notification } from "electron";
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
import { avatarFileCache } from "./avatarFileCacheService";
export interface LinuxNotificationData {
// 系统通知服务(Linux / macOS):走各自系统的通知中心(Linux 底层为
// D-Bus/libnotify,macOS 为通知中心),Windows 使用特制的液态玻璃通知窗口。
// 声音、勿扰模式、通知历史均由系统按用户设置管理
export interface SystemNotificationData {
sessionId?: string;
title: string;
content: string;
@@ -14,6 +18,9 @@ export interface LinuxNotificationData {
type NotificationCallback = (payload: unknown) => void;
const isSupportedPlatform =
process.platform === "linux" || process.platform === "darwin";
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
@@ -39,20 +46,20 @@ function triggerNotificationCallback(payload: unknown): void {
try {
callback(payload);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
console.error("[SystemNotification] Callback error:", error);
}
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
export async function showSystemNotification(
data: SystemNotificationData,
): Promise<number | null> {
if (process.platform !== "linux") {
if (!isSupportedPlatform) {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
console.warn("[SystemNotification] Notification API is not supported");
return null;
}
@@ -91,12 +98,15 @@ export async function showLinuxNotification(
});
notification.on("failed", (_, error) => {
console.error("[LinuxNotification] Notification failed:", error);
console.error("[SystemNotification] Notification failed:", error);
clearNotificationState(notificationId);
});
// Linux 的部分通知服务不会自动过期,需要手动关闭兜底;
// macOS 横幅由系统自动收起并保留在通知中心,手动 close 反而会把它
// 从通知中心移除,因此不做超时关闭
const expireTimeout = data.expireTimeout ?? 5000;
if (expireTimeout > 0) {
if (process.platform === "linux" && expireTimeout > 0) {
const timer = setTimeout(() => {
const currentNotification = activeNotifications.get(notificationId);
if (currentNotification) {
@@ -109,37 +119,16 @@ export async function showLinuxNotification(
notification.show();
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
`[SystemNotification] Shown notification ${notificationId}: ${data.title}`,
);
return notificationId;
} catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error);
console.error("[SystemNotification] Failed to show notification:", error);
return null;
}
}
export async function closeLinuxNotification(
notificationId: number,
): Promise<void> {
const notification = activeNotifications.get(notificationId);
if (!notification) return;
notification.close();
clearNotificationState(notificationId);
}
export async function getCapabilities(): Promise<string[]> {
if (process.platform !== "linux") {
return [];
}
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
}
export function onNotificationAction(callback: NotificationCallback): void {
notificationCallbacks.push(callback);
}
@@ -153,23 +142,21 @@ export function removeNotificationCallback(
}
}
export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init");
export async function initSystemNotificationService(): Promise<void> {
if (!isSupportedPlatform) {
console.log("[SystemNotification] Platform uses custom window, skipping init");
return;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
console.warn("[SystemNotification] Notification API is not supported");
return;
}
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
console.log("[SystemNotification] Service initialized for", process.platform);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
export async function shutdownSystemNotificationService(): Promise<void> {
for (const [id, notification] of activeNotifications) {
try {
notification.close();
@@ -182,5 +169,5 @@ export async function shutdownLinuxNotificationService(): Promise<void> {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
console.log("[SystemNotification] Service shutdown complete");
}
+12 -12
View File
@@ -161,13 +161,18 @@ class VideoService {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
/**
* 判断 dbPath 本身是否就是账号目录。
* 无论 wxid_ 开头还是自定义微信号,账号目录下必然存在 db_storage 子目录,
* 以此为判据。不可用路径子串匹配 wxid(#1128:Windows 用户名与微信号相同时,
* `C:\Users\<同名>` 会让子串匹配误判,导致视频目录解析到错误位置)。
*/
private isAccountDir(dirPath: string): boolean {
return existsSync(join(dirPath, 'db_storage'))
}
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
if (this.isAccountDir(dbPath)) {
return join(dbPath, 'msg', 'video')
}
@@ -182,12 +187,7 @@ class VideoService {
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
if (this.isAccountDir(dbPath)) {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
+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));
}
});