From d6b95036b523cb8cec408a1681ef102edba8d058 Mon Sep 17 00:00:00 2001
From: cc <98377878+hicccc77@users.noreply.github.com>
Date: Sun, 15 Mar 2026 11:42:41 +0800
Subject: [PATCH 01/39] =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=AE=80=E5=8D=95?=
=?UTF-8?q?=E7=9A=84=E5=AE=89=E5=8D=93=E5=B2=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
electron/services/config.ts | 2 +-
electron/windows/notificationWindow.ts | 8 +++++--
src/components/GlobalSessionMonitor.tsx | 4 ++--
src/components/NotificationToast.scss | 19 +++++++++++++++
src/components/NotificationToast.tsx | 2 +-
src/pages/ChatPage.tsx | 14 ++++-------
src/pages/NotificationWindow.scss | 32 +++++++++++++++++++++++++
src/pages/NotificationWindow.tsx | 14 ++++++++---
src/pages/SettingsPage.scss | 2 +-
src/pages/SettingsPage.tsx | 8 ++++---
10 files changed, 83 insertions(+), 22 deletions(-)
diff --git a/electron/services/config.ts b/electron/services/config.ts
index 6ec8270..689521b 100644
--- a/electron/services/config.ts
+++ b/electron/services/config.ts
@@ -47,7 +47,7 @@ interface ConfigSchema {
// 通知
notificationEnabled: boolean
- notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
+ notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
wordCloudExcludeWords: string[]
diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts
index 1642924..fc31ccc 100644
--- a/electron/windows/notificationWindow.ts
+++ b/electron/windows/notificationWindow.ts
@@ -132,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
// 更新位置
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
- const winWidth = 344
+ const winWidth = position === 'top-center' ? 280 : 344
const winHeight = 114
const padding = 20
@@ -140,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
let y = 0
switch (position) {
+ case 'top-center':
+ x = (screenWidth - winWidth) / 2
+ y = padding
+ break
case 'top-right':
x = screenWidth - winWidth - padding
y = padding
@@ -166,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
win.showInactive() // 显示但不聚焦
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
- win.webContents.send('notification:show', data)
+ win.webContents.send('notification:show', { ...data, position })
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx
index a1abf71..93c9a47 100644
--- a/src/components/GlobalSessionMonitor.tsx
+++ b/src/components/GlobalSessionMonitor.tsx
@@ -96,8 +96,8 @@ export function GlobalSessionMonitor() {
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
// 这是新消息事件
- // 免打扰、折叠群、折叠入口不弹通知
- if (newSession.isMuted || newSession.isFolded) continue
+ // 折叠群、折叠入口不弹通知
+ if (newSession.isFolded) continue
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
// 1. 群聊过滤自己发送的消息
diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss
index a01ab73..57dc558 100644
--- a/src/components/NotificationToast.scss
+++ b/src/components/NotificationToast.scss
@@ -134,6 +134,25 @@
}
}
+ &.top-center {
+ top: 24px;
+ left: 50%;
+ transform: translate(-50%, -20px) scale(0.95);
+
+ &.visible {
+ transform: translate(-50%, 0) scale(1);
+ }
+
+ // 灵动岛样式
+ border-radius: 40px !important;
+ padding: 12px 16px;
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
+
+ &.static {
+ border-radius: 40px !important;
+ }
+ }
+
&:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}
diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx
index 886a878..f394f6c 100644
--- a/src/components/NotificationToast.tsx
+++ b/src/components/NotificationToast.tsx
@@ -18,7 +18,7 @@ interface NotificationToastProps {
onClose: () => void
onClick: (sessionId: string) => void
duration?: number
- position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean
initialVisible?: boolean
}
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 50e1534..871f415 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
+import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
@@ -377,7 +377,7 @@ const SessionItem = React.memo(function SessionItem({
return (
onSelect(session)}
>
{session.summary || '暂无消息'}
- {session.isMuted &&
}
{session.unreadCount > 0 && (
-
+
{session.unreadCount > 99 ? '99+' : session.unreadCount}
)}
@@ -414,7 +413,6 @@ const SessionItem = React.memo(function SessionItem({
prevProps.session.unreadCount === nextProps.session.unreadCount &&
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
- prevProps.session.isMuted === nextProps.session.isMuted &&
prevProps.isActive === nextProps.isActive
)
})
@@ -1898,16 +1896,14 @@ function ChatPage(props: ChatPageProps) {
if (!status) return session
const nextIsFolded = status.isFolded ?? session.isFolded
- const nextIsMuted = status.isMuted ?? session.isMuted
- if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) {
+ if (nextIsFolded === session.isFolded) {
return session
}
hasChanges = true
return {
...session,
- isFolded: nextIsFolded,
- isMuted: nextIsMuted
+ isFolded: nextIsFolded
}
})
diff --git a/src/pages/NotificationWindow.scss b/src/pages/NotificationWindow.scss
index 3e1515d..5c92a13 100644
--- a/src/pages/NotificationWindow.scss
+++ b/src/pages/NotificationWindow.scss
@@ -10,6 +10,18 @@
}
}
+@keyframes noti-enter-center {
+ 0% {
+ opacity: 0;
+ transform: translateY(-50px) scale(0.7);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
@keyframes noti-exit {
0% {
opacity: 1;
@@ -24,6 +36,18 @@
}
}
+@keyframes noti-exit-center {
+ 0% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateY(-50px) scale(0.7);
+ }
+}
+
body {
// Ensure the body background is transparent to let the rounded corners show
background: transparent;
@@ -41,6 +65,10 @@ body {
// New notification slides in
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
will-change: transform, opacity;
+
+ &.anim-center {
+ animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+ }
}
#notification-prev {
@@ -51,4 +79,8 @@ body {
// Ensure it stays behind
z-index: 0 !important;
+
+ &.anim-center {
+ animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
+ }
}
\ No newline at end of file
diff --git a/src/pages/NotificationWindow.tsx b/src/pages/NotificationWindow.tsx
index deb6616..62cdb72 100644
--- a/src/pages/NotificationWindow.tsx
+++ b/src/pages/NotificationWindow.tsx
@@ -6,8 +6,9 @@ import './NotificationWindow.scss'
export default function NotificationWindow() {
const [notification, setNotification] = useState(null)
const [prevNotification, setPrevNotification] = useState(null)
+ const [position, setPosition] = useState('top-right')
- // We need a ref to access the current notification inside the callback
+ // We need a ref to access the current notification inside the callback
// without satisfying the dependency array which would recreate the listener
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
// So we use setNotification callback: setNotification(current => { ... return newNode })
@@ -34,6 +35,11 @@ export default function NotificationWindow() {
avatarUrl: data.avatarUrl
}
+ // 获取位置配置
+ if (data.position) {
+ setPosition(data.position)
+ }
+
// Set previous to current (ref)
if (notificationRef.current) {
setPrevNotification(notificationRef.current)
@@ -117,6 +123,7 @@ export default function NotificationWindow() {
{ }} // No-op for background item
onClick={() => { }}
- position="top-right"
+ position={position as any}
isStatic={true}
initialVisible={true}
/>
@@ -143,6 +150,7 @@ export default function NotificationWindow() {
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss
index 04a5751..10b6730 100644
--- a/src/pages/SettingsPage.scss
+++ b/src/pages/SettingsPage.scss
@@ -1,6 +1,6 @@
.settings-modal-overlay {
position: fixed;
- top: 41px;
+ top: 0;
left: 0;
right: 0;
bottom: 0;
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index d963b14..db817e2 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -102,7 +102,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [transcribeLanguages, setTranscribeLanguages] = useState
(['zh'])
const [notificationEnabled, setNotificationEnabled] = useState(true)
- const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
+ const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState([])
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
@@ -1102,12 +1102,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{notificationPosition === 'top-right' ? '右上角' :
notificationPosition === 'bottom-right' ? '右下角' :
- notificationPosition === 'top-left' ? '左上角' : '左下角'}
+ notificationPosition === 'top-left' ? '左上角' :
+ notificationPosition === 'top-center' ? '中间上方' : '左下角'}
{[
+ { value: 'top-center', label: '中间上方' },
{ value: 'top-right', label: '右上角' },
{ value: 'bottom-right', label: '右下角' },
{ value: 'top-left', label: '左上角' },
@@ -1117,7 +1119,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
key={option.value}
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
onClick={async () => {
- const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
+ const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
setNotificationPosition(val)
setPositionDropdownOpen(false)
await configService.setNotificationPosition(val)
From 2b97b6ac9d984f4bc3562ecacc742b238c6a9b86 Mon Sep 17 00:00:00 2001
From: cc <98377878+hicccc77@users.noreply.github.com>
Date: Sun, 15 Mar 2026 12:17:13 +0800
Subject: [PATCH 02/39] =?UTF-8?q?=E6=9B=B4=E6=96=B0mac=20sip=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E6=A3=80=E6=B5=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
electron/services/keyServiceMac.ts | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts
index 5f1e34f..494e1fb 100644
--- a/electron/services/keyServiceMac.ts
+++ b/electron/services/keyServiceMac.ts
@@ -116,11 +116,30 @@ export class KeyServiceMac {
}
}
+ private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> {
+ try {
+ const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
+ const enabled = stdout.toLowerCase().includes('enabled')
+ return { enabled }
+ } catch (e: any) {
+ return { enabled: false, error: e.message }
+ }
+ }
+
async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise
{
try {
+ // 检测 SIP 状态
+ const sipStatus = await this.checkSipStatus()
+ if (sipStatus.enabled) {
+ return {
+ success: false,
+ error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑'
+ }
+ }
+
onStatus?.('正在获取数据库密钥...', 0)
onStatus?.('正在请求管理员授权并执行 helper...', 0)
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
@@ -764,7 +783,7 @@ export class KeyServiceMac {
}
const current = chunk.subarray(0, bytesRead)
- const data = trailing ? Buffer.concat([trailing, current]) : current
+ const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
if (key) return key
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
@@ -793,8 +812,8 @@ export class KeyServiceMac {
}
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
let stdout = '', stderr = ''
- child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
- child.stderr.on('data', (chunk: Buffer) => {
+ child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
+ child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
console.log(tag, chunk.toString().trim())
})
From 7be2c692562f373d9a07074cd32bcfb4f04eff20 Mon Sep 17 00:00:00 2001
From: hicccc77 <98377878+hicccc77@users.noreply.github.com>
Date: Sun, 15 Mar 2026 14:15:51 +0800
Subject: [PATCH 03/39] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20getAvatarUrls?=
=?UTF-8?q?=20=E7=AB=9E=E6=80=81=E5=AF=BC=E8=87=B4=20handle=20=E4=B8=BA=20?=
=?UTF-8?q?null=20=E7=9A=84=E5=B4=A9=E6=BA=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
在 await setImmediate 让出控制权前先捕获 handle,
await 后重新校验 handle 是否仍有效,避免连接关闭后
向 koffi DLL 传入 null 导致 TypeError。
---
electron/services/wcdbCore.ts | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts
index 7e69caa..517fedf 100644
--- a/electron/services/wcdbCore.ts
+++ b/electron/services/wcdbCore.ts
@@ -1488,10 +1488,19 @@ export class WcdbCore {
}
// 让出控制权,避免阻塞事件循环
+ const handle = this.handle
await new Promise(resolve => setImmediate(resolve))
+ // await 后 handle 可能已被关闭,需重新检查
+ if (handle === null || this.handle !== handle) {
+ if (Object.keys(resultMap).length > 0) {
+ return { success: true, map: resultMap, error: '连接已断开' }
+ }
+ return { success: false, error: '连接已断开' }
+ }
+
const outPtr = [null as any]
- const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr)
+ const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
From 6741a94c1b9d9ceb73365973a497856ed95a5ccf Mon Sep 17 00:00:00 2001
From: pisauvage <8958673+pisauvage@users.noreply.github.com>
Date: Sun, 15 Mar 2026 15:29:54 +0900
Subject: [PATCH 04/39] fix(mac): support non-wxid account dirs for image keys
---
electron/services/keyServiceMac.ts | 197 ++++++++++++++++++++++-------
1 file changed, 148 insertions(+), 49 deletions(-)
diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts
index 5f1e34f..9f1e9da 100644
--- a/electron/services/keyServiceMac.ts
+++ b/electron/services/keyServiceMac.ts
@@ -488,26 +488,39 @@ export class KeyServiceMac {
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
if (wxidCandidates.length === 0) {
- return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
+ return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' }
}
+ const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
+
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
- let verifyCiphertext: Buffer | null = null
- if (accountPath && existsSync(accountPath)) {
- const template = await this._findTemplateData(accountPath, 32)
- verifyCiphertext = template.ciphertext
- }
- if (verifyCiphertext) {
+ if (accountPathCandidates.length > 0) {
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
- for (const candidateWxid of wxidCandidates) {
- for (const code of codes) {
- const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
- if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
- onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
- return { success: true, xorKey, aesKey }
+ for (const candidateAccountPath of accountPathCandidates) {
+ if (!existsSync(candidateAccountPath)) continue
+ const template = await this._findTemplateData(candidateAccountPath, 32)
+ if (!template.ciphertext) continue
+
+ const accountDirWxid = basename(candidateAccountPath)
+ const orderedWxids: string[] = []
+ this.pushAccountIdCandidates(orderedWxids, accountDirWxid)
+ for (const candidate of wxidCandidates) {
+ this.pushAccountIdCandidates(orderedWxids, candidate)
+ }
+
+ for (const candidateWxid of orderedWxids) {
+ for (const code of codes) {
+ const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
+ if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
+ onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
+ return { success: true, xorKey, aesKey }
+ }
}
}
- return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
+ return {
+ success: false,
+ error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。'
+ }
}
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
@@ -542,16 +555,21 @@ export class KeyServiceMac {
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
- // 2. 找微信 PID
- const pid = await this.findWeChatPid()
- if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
-
- onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
-
- // 3. 持续轮询内存扫描
+ // 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化
const deadline = Date.now() + 60_000
let scanCount = 0
+ let lastPid: number | null = null
while (Date.now() < deadline) {
+ const pid = await this.findWeChatPid()
+ if (!pid) {
+ onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...')
+ await new Promise(r => setTimeout(r, 2000))
+ continue
+ }
+ if (lastPid !== pid) {
+ lastPid = pid
+ onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
+ }
scanCount++
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
@@ -764,7 +782,7 @@ export class KeyServiceMac {
}
const current = chunk.subarray(0, bytesRead)
- const data = trailing ? Buffer.concat([trailing, current]) : current
+ const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
if (key) return key
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
@@ -793,8 +811,8 @@ export class KeyServiceMac {
}
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
let stdout = '', stderr = ''
- child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
- child.stderr.on('data', (chunk: Buffer) => {
+ child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
+ child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
console.log(tag, chunk.toString().trim())
})
@@ -819,11 +837,8 @@ export class KeyServiceMac {
}
private async findWeChatPid(): Promise {
- const { execSync } = await import('child_process')
try {
- const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
- const pid = parseInt(output.trim())
- return isNaN(pid) ? null : pid
+ return await this.getWeChatPid()
} catch {
return null
}
@@ -840,12 +855,70 @@ export class KeyServiceMac {
this.machPortDeallocate = null
}
+ private normalizeAccountId(value: string): string {
+ const trimmed = String(value || '').trim()
+ if (!trimmed) return ''
+
+ if (trimmed.toLowerCase().startsWith('wxid_')) {
+ const match = trimmed.match(/^(wxid_[^_]+)/i)
+ return match?.[1] || trimmed
+ }
+
+ const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
+ return suffixMatch ? suffixMatch[1] : trimmed
+ }
+
+ private isIgnoredAccountName(value: string): boolean {
+ const lowered = String(value || '').trim().toLowerCase()
+ if (!lowered) return true
+ return lowered === 'xwechat_files' ||
+ lowered === 'all_users' ||
+ lowered === 'backup' ||
+ lowered === 'wmpf' ||
+ lowered === 'app_data'
+ }
+
+ private isReasonableAccountId(value: string): boolean {
+ const trimmed = String(value || '').trim()
+ if (!trimmed) return false
+ if (trimmed.includes('/') || trimmed.includes('\\')) return false
+ return !this.isIgnoredAccountName(trimmed)
+ }
+
+ private isAccountDirPath(entryPath: string): boolean {
+ return existsSync(join(entryPath, 'db_storage')) ||
+ existsSync(join(entryPath, 'msg')) ||
+ existsSync(join(entryPath, 'FileStorage', 'Image')) ||
+ existsSync(join(entryPath, 'FileStorage', 'Image2'))
+ }
+
+ private resolveXwechatRootFromPath(accountPath?: string): string | null {
+ const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
+ if (!normalized) return null
+ const marker = '/xwechat_files'
+ const markerIdx = normalized.indexOf(marker)
+ if (markerIdx < 0) return null
+ return normalized.slice(0, markerIdx + marker.length)
+ }
+
+ private pushAccountIdCandidates(candidates: string[], value?: string): void {
+ const pushUnique = (item: string) => {
+ const trimmed = String(item || '').trim()
+ if (!trimmed || candidates.includes(trimmed)) return
+ candidates.push(trimmed)
+ }
+
+ const raw = String(value || '').trim()
+ if (!this.isReasonableAccountId(raw)) return
+ pushUnique(raw)
+ const normalized = this.normalizeAccountId(raw)
+ if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) {
+ pushUnique(normalized)
+ }
+ }
+
private cleanWxid(wxid: string): string {
- const first = wxid.indexOf('_')
- if (first === -1) return wxid
- const second = wxid.indexOf('_', first + 1)
- if (second === -1) return wxid
- return wxid.substring(0, second)
+ return this.normalizeAccountId(wxid)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
@@ -858,32 +931,59 @@ export class KeyServiceMac {
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
const candidates: string[] = []
- const pushUnique = (value: string) => {
- const v = String(value || '').trim()
- if (!v || candidates.includes(v)) return
- candidates.push(v)
- }
// 1) 显式传参优先
- if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
+ this.pushAccountIdCandidates(candidates, wxidParam)
if (accountPath) {
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
const dirName = basename(normalized)
- // 2) 当前目录名为 wxid_*
- if (dirName.startsWith('wxid_')) pushUnique(dirName)
+ // 2) 当前目录名本身就是账号目录
+ this.pushAccountIdCandidates(candidates, dirName)
- // 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
- const marker = '/xwechat_files'
- const markerIdx = normalized.indexOf(marker)
- if (markerIdx >= 0) {
- const root = normalized.slice(0, markerIdx + marker.length)
+ // 3) 从 xwechat_files 根目录枚举全部账号目录
+ const root = this.resolveXwechatRootFromPath(accountPath)
+ if (root) {
if (existsSync(root)) {
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
- if (!entry.name.startsWith('wxid_')) continue
- pushUnique(entry.name)
+ const entryPath = join(root, entry.name)
+ if (!this.isAccountDirPath(entryPath)) continue
+ this.pushAccountIdCandidates(candidates, entry.name)
+ }
+ } catch {
+ // ignore
+ }
+ }
+ }
+ }
+
+ if (candidates.length === 0) candidates.push('unknown')
+ return candidates
+ }
+
+ private collectAccountPathCandidates(accountPath?: string): string[] {
+ const candidates: string[] = []
+ const pushUnique = (value?: string) => {
+ const v = String(value || '').trim()
+ if (!v || candidates.includes(v)) return
+ candidates.push(v)
+ }
+
+ if (accountPath) pushUnique(accountPath)
+
+ if (accountPath) {
+ const root = this.resolveXwechatRootFromPath(accountPath)
+ if (root) {
+ if (existsSync(root)) {
+ try {
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue
+ const entryPath = join(root, entry.name)
+ if (!this.isAccountDirPath(entryPath)) continue
+ if (!this.isReasonableAccountId(entry.name)) continue
+ pushUnique(entryPath)
}
} catch {
// ignore
@@ -892,7 +992,6 @@ export class KeyServiceMac {
}
}
- pushUnique('unknown')
return candidates
}
From caaf1e8d0ddfc0070f6ea22abddac9883aaf952f Mon Sep 17 00:00:00 2001
From: xuncha <1658671838@qq.com>
Date: Sun, 15 Mar 2026 18:31:57 +0800
Subject: [PATCH 05/39] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=85=A5?=
=?UTF-8?q?=E5=88=B0=E7=94=B5=E8=84=91=E4=B8=8A=E7=9A=84=E5=9B=BE=E7=89=87?=
=?UTF-8?q?=E6=97=A0=E6=B3=95=E8=A7=A3=E5=AF=86=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
electron/services/imageDecryptService.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts
index a78b7ed..9ad2c25 100644
--- a/electron/services/imageDecryptService.ts
+++ b/electron/services/imageDecryptService.ts
@@ -436,6 +436,10 @@ export class ImageDecryptService {
if (imageMd5) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res
+ if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
+ const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
+ if (datNameRes) return datNameRes
+ }
}
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
@@ -889,7 +893,8 @@ export class ImageDecryptService {
const now = new Date()
const months: string[] = []
- for (let i = 0; i < 2; i++) {
+ // Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
+ for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
months.push(mStr)
From c0ad4509608d9a2743a8d277e70ed96d4d882e0f Mon Sep 17 00:00:00 2001
From: 2977094657 <2977094657@qq.com>
Date: Sun, 15 Mar 2026 19:08:13 +0800
Subject: [PATCH 06/39] fix(chat): stabilize history pagination and message
keys
---
electron/services/chatService.ts | 366 ++++++++++++++++--------
src/components/GlobalSessionMonitor.tsx | 19 +-
src/pages/ChatPage.tsx | 115 +++++---
src/stores/chatStore.ts | 5 +-
src/types/electron.d.ts | 2 +
src/types/models.ts | 2 +
6 files changed, 337 insertions(+), 172 deletions(-)
diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts
index 0b81fe2..e8da599 100644
--- a/electron/services/chatService.ts
+++ b/electron/services/chatService.ts
@@ -37,6 +37,7 @@ export interface ChatSession {
}
export interface Message {
+ messageKey: string
localId: number
serverId: number
localType: number
@@ -1433,7 +1434,7 @@ class ChatService {
startTime: number = 0,
endTime: number = 0,
ascending: boolean = false
- ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
+ ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
let releaseMessageCursorMutex: (() => void) | null = null
try {
const connectResult = await this.ensureConnected()
@@ -1492,7 +1493,6 @@ class ChatService {
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
this.messageCursors.set(sessionId, state)
- releaseMessageCursorMutex?.()
// 如果需要跳过消息(offset > 0),逐批获取但不返回
// 注意:仅在 offset === 0 时重建游标最安全;
@@ -1512,7 +1512,7 @@ class ChatService {
}
if (!skipBatch.rows || skipBatch.rows.length === 0) {
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
- return { success: true, messages: [], hasMore: false }
+ return { success: true, messages: [], hasMore: false, nextOffset: skipped }
}
const count = skipBatch.rows.length
@@ -1531,7 +1531,7 @@ class ChatService {
if (!skipBatch.hasMore) {
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
- return { success: true, messages: [], hasMore: false }
+ return { success: true, messages: [], hasMore: false, nextOffset: skipped }
}
}
if (attempts >= maxSkipAttempts) {
@@ -1548,91 +1548,28 @@ class ChatService {
return { success: false, error: '游标状态未初始化' }
}
- // 获取当前批次的消息
- // Use buffered rows from skip logic if available
- let rows: any[] = state.bufferedMessages || []
- state.bufferedMessages = undefined // Clear buffer after use
-
- // Track actual hasMore status from C++ layer
- // If we have buffered messages, we need to check if there's more data
- let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
-
- // If buffer is not enough to fill a batch, try to fetch more
- // Or if buffer is empty, fetch a batch
- if (rows.length < batchSize) {
- const nextBatch = await wcdbService.fetchMessageBatch(state.cursor)
- if (nextBatch.success && nextBatch.rows) {
- rows = rows.concat(nextBatch.rows)
- actualHasMore = nextBatch.hasMore === true
- } else if (!nextBatch.success) {
- console.error('[ChatService] 获取消息批次失败:', nextBatch.error)
- // If we have some buffered rows, we can still return them?
- // Or fail? Let's return what we have if any, otherwise fail.
- if (rows.length === 0) {
- return { success: false, error: nextBatch.error || '获取消息失败' }
- }
- actualHasMore = false
- }
+ const collected = await this.collectVisibleMessagesFromCursor(
+ sessionId,
+ state.cursor,
+ limit,
+ state.bufferedMessages as Record[] | undefined
+ )
+ state.bufferedMessages = collected.bufferedRows
+ if (!collected.success) {
+ return { success: false, error: collected.error || '获取消息失败' }
}
- // If we have more than limit (due to buffer + full batch), slice it
- if (rows.length > limit) {
- rows = rows.slice(0, limit)
- // Note: We don't adjust state.fetched here because it tracks cursor position.
- // Next time offset will catch up or mismatch trigger reset.
- }
-
- // Use actual hasMore from C++ layer, not simplified row count check
- const hasMore = actualHasMore
-
- const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
-
- // 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
- const filtered = normalized.filter(msg => {
- // 检查消息的 senderUsername 或 rawContent 中的 talker
- // 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文
- // 单聊消息:senderUsername 应该是 sessionId 或自己
- const isGroupChat = sessionId.includes('@chatroom')
-
- if (isGroupChat) {
- // 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId)
- return true
- } else {
- // 单聊消息:senderUsername 应该是 sessionId(对方)或为空/null(自己)
- if (!msg.senderUsername || msg.senderUsername === sessionId) {
- return true
- }
- // 如果 isSend 为 1,说明是自己发的,允许通过
- if (msg.isSend === 1) {
- return true
- }
- // 其他情况:可能是错误的消息
- console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
- return false
- }
- })
-
- if (filtered.length < normalized.length) {
- console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
- }
-
- // 并发检查并修复缺失 CDN URL 的表情包
- const fixPromises: Promise[] = []
- for (const msg of filtered) {
- if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
- fixPromises.push(this.fallbackEmoticon(msg))
- }
- }
-
- if (fixPromises.length > 0) {
- await Promise.allSettled(fixPromises)
- }
-
- state.fetched += rows.length
+ const rawRowsConsumed = collected.rawRowsConsumed || 0
+ const filtered = collected.messages || []
+ const hasMore = collected.hasMore === true
+ state.fetched += rawRowsConsumed
releaseMessageCursorMutex?.()
this.messageCacheService.set(sessionId, filtered)
- return { success: true, messages: filtered, hasMore }
+ console.log(
+ `[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
+ )
+ return { success: true, messages: filtered, hasMore, nextOffset: state.fetched }
} catch (e) {
console.error('ChatService: 获取消息失败:', e)
return { success: false, error: String(e) }
@@ -1732,7 +1669,7 @@ class ChatService {
}
- async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
+ async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
@@ -1746,24 +1683,19 @@ class ChatService {
}
try {
- const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
- if (!batch.success || !batch.rows) {
- return { success: false, error: batch.error || '获取消息失败' }
+ const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit)
+ if (!collected.success) {
+ return { success: false, error: collected.error || '获取消息失败' }
}
- const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record[]))
-
- // 并发检查并修复缺失 CDN URL 的表情包
- const fixPromises: Promise[] = []
- for (const msg of normalized) {
- if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
- fixPromises.push(this.fallbackEmoticon(msg))
- }
+ console.log(
+ `[ChatService] getLatestMessages session=${sessionId} rawRowsConsumed=${collected.rawRowsConsumed || 0} visibleMessagesReturned=${collected.messages?.length || 0} filteredOut=${collected.filteredOut || 0} nextOffset=${collected.rawRowsConsumed || 0} hasMore=${collected.hasMore === true}`
+ )
+ return {
+ success: true,
+ messages: collected.messages,
+ hasMore: collected.hasMore,
+ nextOffset: collected.rawRowsConsumed || 0
}
- if (fixPromises.length > 0) {
- await Promise.allSettled(fixPromises)
- }
-
- return { success: true, messages: normalized, hasMore: batch.hasMore === true }
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
@@ -1819,6 +1751,174 @@ class ChatService {
return messages
}
+ private encodeMessageKeySegment(value: unknown): string {
+ const normalized = String(value ?? '').trim()
+ return encodeURIComponent(normalized)
+ }
+
+ private getMessageSourceInfo(row: Record): { dbName?: string; tableName?: string; dbPath?: string } {
+ const dbPath = String(
+ this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
+ || ''
+ ).trim()
+ const explicitDbName = String(
+ this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
+ || ''
+ ).trim()
+ const tableName = String(
+ this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
+ || ''
+ ).trim()
+ const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
+ return {
+ dbName: dbName || undefined,
+ tableName: tableName || undefined,
+ dbPath: dbPath || undefined
+ }
+ }
+
+ private buildMessageKey(input: {
+ localId: number
+ serverId: number
+ createTime: number
+ sortSeq: number
+ senderUsername?: string | null
+ localType: number
+ dbName?: string
+ tableName?: string
+ dbPath?: string
+ }): string {
+ const localId = Number.isFinite(input.localId) ? Math.max(0, Math.floor(input.localId)) : 0
+ const serverId = Number.isFinite(input.serverId) ? Math.max(0, Math.floor(input.serverId)) : 0
+ const createTime = Number.isFinite(input.createTime) ? Math.max(0, Math.floor(input.createTime)) : 0
+ const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0
+ const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0
+ const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '')
+ const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '')
+ const tableName = String(input.tableName || '').trim()
+
+ if (localId > 0 && dbName && tableName) {
+ return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}`
+ }
+
+ if (serverId > 0) {
+ return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
+ }
+
+ return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
+ }
+
+ private isMessageVisibleForSession(sessionId: string, msg: Message): boolean {
+ const isGroupChat = sessionId.includes('@chatroom')
+ if (isGroupChat) {
+ return true
+ }
+ if (!msg.senderUsername || msg.senderUsername === sessionId) {
+ return true
+ }
+ if (msg.isSend === 1) {
+ return true
+ }
+ console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
+ return false
+ }
+
+ private async repairEmojiMessages(messages: Message[]): Promise {
+ const fixPromises: Promise[] = []
+ for (const msg of messages) {
+ if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
+ fixPromises.push(this.fallbackEmoticon(msg))
+ }
+ }
+ if (fixPromises.length > 0) {
+ await Promise.allSettled(fixPromises)
+ }
+ }
+
+ private async collectVisibleMessagesFromCursor(
+ sessionId: string,
+ cursor: number,
+ limit: number,
+ initialRows: Record[] = []
+ ): Promise<{
+ success: boolean
+ messages?: Message[]
+ hasMore?: boolean
+ error?: string
+ rawRowsConsumed?: number
+ filteredOut?: number
+ bufferedRows?: Record[]
+ }> {
+ const visibleMessages: Message[] = []
+ let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : []
+ let rawRowsConsumed = 0
+ let filteredOut = 0
+ let cursorMayHaveMore = queuedRows.length > 0
+
+ while (visibleMessages.length < limit) {
+ if (queuedRows.length === 0) {
+ const batch = await wcdbService.fetchMessageBatch(cursor)
+ if (!batch.success) {
+ console.error('[ChatService] 获取消息批次失败:', batch.error)
+ if (visibleMessages.length === 0) {
+ return { success: false, error: batch.error || '获取消息失败' }
+ }
+ cursorMayHaveMore = false
+ break
+ }
+
+ const batchRows = Array.isArray(batch.rows) ? batch.rows as Record[] : []
+ cursorMayHaveMore = batch.hasMore === true
+ if (batchRows.length === 0) {
+ break
+ }
+ queuedRows = batchRows
+ }
+
+ const rowsToProcess = queuedRows
+ queuedRows = []
+ const mappedMessages = this.mapRowsToMessages(rowsToProcess)
+ for (let index = 0; index < mappedMessages.length; index += 1) {
+ const msg = mappedMessages[index]
+ rawRowsConsumed += 1
+ if (this.isMessageVisibleForSession(sessionId, msg)) {
+ visibleMessages.push(msg)
+ if (visibleMessages.length >= limit) {
+ if (index + 1 < rowsToProcess.length) {
+ queuedRows = rowsToProcess.slice(index + 1)
+ }
+ break
+ }
+ } else {
+ filteredOut += 1
+ }
+ }
+
+ if (visibleMessages.length >= limit) {
+ break
+ }
+
+ if (!cursorMayHaveMore) {
+ break
+ }
+ }
+
+ if (filteredOut > 0) {
+ console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`)
+ }
+
+ const normalized = this.normalizeMessageOrder(visibleMessages)
+ await this.repairEmojiMessages(normalized)
+ return {
+ success: true,
+ messages: normalized,
+ hasMore: queuedRows.length > 0 || cursorMayHaveMore,
+ rawRowsConsumed,
+ filteredOut,
+ bufferedRows: queuedRows.length > 0 ? queuedRows : undefined
+ }
+ }
+
private getRowField(row: Record, keys: string[]): any {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key]
@@ -2954,6 +3054,7 @@ class ChatService {
const messages: Message[] = []
for (const row of rows) {
+ const sourceInfo = this.getMessageSourceInfo(row)
const rawMessageContent = this.getRowField(row, [
'message_content',
'messageContent',
@@ -3160,12 +3261,25 @@ class ChatService {
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
}
+ const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
+ const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
+ const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
+
messages.push({
- localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
- serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
+ messageKey: this.buildMessageKey({
+ localId,
+ serverId,
+ createTime,
+ sortSeq,
+ senderUsername,
+ localType,
+ ...sourceInfo
+ }),
+ localId,
+ serverId,
localType,
createTime,
- sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime),
+ sortSeq,
isSend,
senderUsername,
parsedContent: this.parseMessageContent(content, localType),
@@ -3217,7 +3331,8 @@ class ChatService {
transferPayerUsername,
transferReceiverUsername,
chatRecordTitle,
- chatRecordList
+ chatRecordList,
+ _db_path: sourceInfo.dbPath
})
const last = messages[messages.length - 1]
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
@@ -6564,7 +6679,11 @@ class ChatService {
const result = await wcdbService.execQuery('message', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
- const row = result.rows[0]
+ const row = {
+ ...(result.rows[0] as Record),
+ db_path: dbPath,
+ table_name: tableName
+ }
const message = this.parseMessage(row)
if (message.localId !== 0) {
@@ -6581,6 +6700,7 @@ class ChatService {
}
private parseMessage(row: any): Message {
+ const sourceInfo = this.getMessageSourceInfo(row)
const rawContent = this.decodeMessageContent(
this.getRowField(row, [
'message_content',
@@ -6601,19 +6721,35 @@ class ChatService {
)
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
+ const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
+ const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
+ const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
+ const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
+ const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
+ const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
+ || this.extractSenderUsernameFromContent(rawContent)
+ || null
const msg: Message = {
- localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
- serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
- localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0),
- createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
- sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)),
+ messageKey: this.buildMessageKey({
+ localId,
+ serverId,
+ createTime,
+ sortSeq,
+ senderUsername,
+ localType,
+ ...sourceInfo
+ }),
+ localId,
+ serverId,
+ localType,
+ createTime,
+ sortSeq,
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
- senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
- || this.extractSenderUsernameFromContent(rawContent)
- || null,
+ senderUsername,
rawContent: rawContent,
content: rawContent, // 添加原始内容供视频MD5解析使用
- parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
+ parsedContent: this.parseMessageContent(rawContent, localType),
+ _db_path: sourceInfo.dbPath
}
if (msg.localId === 0 || msg.createTime === 0) {
diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx
index 93c9a47..a8f65b0 100644
--- a/src/components/GlobalSessionMonitor.tsx
+++ b/src/components/GlobalSessionMonitor.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chatStore'
-import type { ChatSession } from '../types/models'
+import type { ChatSession, Message } from '../types/models'
import { useNavigate } from 'react-router-dom'
export function GlobalSessionMonitor() {
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
}, [sessions])
// 去重辅助函数:获取消息 key
- const getMessageKey = (msg: any) => {
- if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
- return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
+ const getMessageKey = (msg: Message) => {
+ if (msg.messageKey) return msg.messageKey
+ return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
}
// 处理数据库变更
@@ -96,8 +96,8 @@ export function GlobalSessionMonitor() {
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
// 这是新消息事件
- // 折叠群、折叠入口不弹通知
- if (newSession.isFolded) continue
+ // 免打扰、折叠群、折叠入口不弹通知
+ if (newSession.isMuted || newSession.isFolded) continue
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
// 1. 群聊过滤自己发送的消息
@@ -267,7 +267,12 @@ export function GlobalSessionMonitor() {
try {
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
if (result.success && result.messages && result.messages.length > 0) {
- appendMessages(result.messages, false) // 追加到末尾
+ const latestMessages = useChatStore.getState().messages || []
+ const existingKeys = new Set(latestMessages.map(getMessageKey))
+ const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
+ if (newMessages.length > 0) {
+ appendMessages(newMessages, false)
+ }
}
} catch (e) {
console.warn('后台活跃会话刷新失败:', e)
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 871f415..f0f4a7c 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
+import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
@@ -377,7 +377,7 @@ const SessionItem = React.memo(function SessionItem({
return (
onSelect(session)}
>
{session.summary || '暂无消息'}
+ {session.isMuted && }
{session.unreadCount > 0 && (
-
+
{session.unreadCount > 99 ? '99+' : session.unreadCount}
)}
@@ -413,6 +414,7 @@ const SessionItem = React.memo(function SessionItem({
prevProps.session.unreadCount === nextProps.session.unreadCount &&
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
+ prevProps.session.isMuted === nextProps.session.isMuted &&
prevProps.isActive === nextProps.isActive
)
})
@@ -471,8 +473,8 @@ function ChatPage(props: ChatPageProps) {
const sidebarRef = useRef(null)
const getMessageKey = useCallback((msg: Message): string => {
- if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
- return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
+ if (msg.messageKey) return msg.messageKey
+ return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
}, [])
const initialRevealTimerRef = useRef(null)
const sessionListRef = useRef(null)
@@ -537,7 +539,7 @@ function ChatPage(props: ChatPageProps) {
// 多选模式
const [isSelectionMode, setIsSelectionMode] = useState(false)
- const [selectedMessages, setSelectedMessages] = useState>(new Set())
+ const [selectedMessages, setSelectedMessages] = useState>(new Set())
// 编辑消息额外状态
const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw')
@@ -1896,14 +1898,16 @@ function ChatPage(props: ChatPageProps) {
if (!status) return session
const nextIsFolded = status.isFolded ?? session.isFolded
- if (nextIsFolded === session.isFolded) {
+ const nextIsMuted = status.isMuted ?? session.isMuted
+ if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) {
return session
}
hasChanges = true
return {
...session,
- isFolded: nextIsFolded
+ isFolded: nextIsFolded,
+ isMuted: nextIsMuted
}
})
@@ -2353,6 +2357,7 @@ function ChatPage(props: ChatPageProps) {
success: boolean;
messages?: Message[];
hasMore?: boolean;
+ nextOffset?: number;
error?: string
}
if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) {
@@ -2429,7 +2434,10 @@ function ChatPage(props: ChatPageProps) {
}
}
}
- setCurrentOffset(offset + result.messages.length)
+ const nextOffset = typeof result.nextOffset === 'number'
+ ? result.nextOffset
+ : offset + result.messages.length
+ setCurrentOffset(nextOffset)
} else if (!result.success) {
setNoMessageTable(true)
setHasMoreMessages(false)
@@ -3567,38 +3575,38 @@ function ChatPage(props: ChatPageProps) {
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
- const lastSelectedIdRef = useRef(null)
+ const lastSelectedKeyRef = useRef(null)
- const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
+ const handleToggleSelection = useCallback((messageKey: string, isShiftKey: boolean = false) => {
setSelectedMessages(prev => {
const next = new Set(prev)
// Range selection with Shift key
- if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) {
+ if (isShiftKey && lastSelectedKeyRef.current !== null && lastSelectedKeyRef.current !== messageKey) {
const currentMsgs = useChatStore.getState().messages || []
- const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
- const idx2 = currentMsgs.findIndex(m => m.localId === localId)
+ const idx1 = currentMsgs.findIndex(m => getMessageKey(m) === lastSelectedKeyRef.current)
+ const idx2 = currentMsgs.findIndex(m => getMessageKey(m) === messageKey)
if (idx1 !== -1 && idx2 !== -1) {
const start = Math.min(idx1, idx2)
const end = Math.max(idx1, idx2)
for (let i = start; i <= end; i++) {
- next.add(currentMsgs[i].localId)
+ next.add(getMessageKey(currentMsgs[i]))
}
}
} else {
// Normal toggle
- if (next.has(localId)) {
- next.delete(localId)
- lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction.
+ if (next.has(messageKey)) {
+ next.delete(messageKey)
+ lastSelectedKeyRef.current = null
} else {
- next.add(localId)
- lastSelectedIdRef.current = localId
+ next.add(messageKey)
+ lastSelectedKeyRef.current = messageKey
}
}
return next
})
- }, [])
+ }, [getMessageKey])
const formatBatchDateLabel = useCallback((dateStr: string) => {
const [y, m, d] = dateStr.split('-').map(Number)
@@ -3642,11 +3650,12 @@ function ChatPage(props: ChatPageProps) {
// 执行单条删除动作
const performSingleDelete = async (msg: Message) => {
try {
- const dbPathHint = (msg as any)._db_path
+ const targetMessageKey = getMessageKey(msg)
+ const dbPathHint = msg._db_path
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
if (result.success) {
const currentMessages = useChatStore.getState().messages || []
- const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
+ const newMessages = currentMessages.filter(m => getMessageKey(m) !== targetMessageKey)
useChatStore.getState().setMessages(newMessages)
} else {
alert('删除失败: ' + (result.error || '原因未知'))
@@ -3708,7 +3717,7 @@ function ChatPage(props: ChatPageProps) {
if (result.success) {
const currentMessages = useChatStore.getState().messages || []
const newMessages = currentMessages.map(m => {
- if (m.localId === editingMessage.message.localId) {
+ if (getMessageKey(m) === getMessageKey(editingMessage.message)) {
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
}
return m
@@ -3749,37 +3758,44 @@ function ChatPage(props: ChatPageProps) {
try {
const currentMessages = useChatStore.getState().messages || []
- const selectedIds = Array.from(selectedMessages)
- const deletedIds = new Set()
+ const selectedKeys = Array.from(selectedMessages)
+ const deletedKeys = new Set()
- for (let i = 0; i < selectedIds.length; i++) {
+ for (let i = 0; i < selectedKeys.length; i++) {
if (cancelDeleteRef.current) break
- const id = selectedIds[i]
- const msgObj = currentMessages.find(m => m.localId === id)
- const dbPathHint = (msgObj as any)?._db_path
+ const key = selectedKeys[i]
+ const msgObj = currentMessages.find(m => getMessageKey(m) === key)
+ const dbPathHint = msgObj?._db_path
const createTime = msgObj?.createTime || 0
+ const localId = msgObj?.localId || 0
- try {
- const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint)
- if (result.success) {
- deletedIds.add(id)
- }
- } catch (err) {
- console.error(`删除消息 ${id} 失败:`, err)
+ if (!msgObj) {
+ setDeleteProgress({ current: i + 1, total: selectedKeys.length })
+ continue
}
- setDeleteProgress({ current: i + 1, total: selectedIds.length })
+ try {
+ const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, localId, createTime, dbPathHint)
+ if (result.success) {
+ deletedKeys.add(key)
+ }
+ } catch (err) {
+ console.error(`删除消息 ${localId} 失败:`, err)
+ }
+
+ setDeleteProgress({ current: i + 1, total: selectedKeys.length })
}
- const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId))
+ const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedKeys.has(getMessageKey(m)))
useChatStore.getState().setMessages(finalMessages)
setIsSelectionMode(false)
- setSelectedMessages(new Set())
+ setSelectedMessages(new Set())
+ lastSelectedKeyRef.current = null
if (cancelDeleteRef.current) {
- alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`)
+ alert(`操作已中止。已删除 ${deletedKeys.size} 条,剩余记录保留。`)
}
} catch (e) {
alert('批量删除出现错误: ' + String(e))
@@ -4234,7 +4250,8 @@ function ChatPage(props: ChatPageProps) {
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode}
- isSelected={selectedMessages.has(msg.localId)}
+ messageKey={messageKey}
+ isSelected={selectedMessages.has(messageKey)}
onToggleSelection={handleToggleSelection}
/>
@@ -4809,7 +4826,8 @@ function ChatPage(props: ChatPageProps) {
{
setIsSelectionMode(true)
- setSelectedMessages(new Set([contextMenu.message.localId]))
+ setSelectedMessages(new Set
([getMessageKey(contextMenu.message)]))
+ lastSelectedKeyRef.current = getMessageKey(contextMenu.message)
setContextMenu(null)
}}>
@@ -5085,7 +5103,8 @@ function ChatPage(props: ChatPageProps) {
className="btn-secondary"
onClick={() => {
setIsSelectionMode(false)
- setSelectedMessages(new Set())
+ setSelectedMessages(new Set())
+ lastSelectedKeyRef.current = null
}}
style={{
padding: '6px 16px',
@@ -5163,6 +5182,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
// 消息气泡组件
function MessageBubble({
message,
+ messageKey,
session,
showTime,
myAvatarUrl,
@@ -5174,6 +5194,7 @@ function MessageBubble({
onToggleSelection
}: {
message: Message;
+ messageKey: string;
session: ChatSession;
showTime?: boolean;
myAvatarUrl?: string;
@@ -5182,7 +5203,7 @@ function MessageBubble({
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
isSelectionMode?: boolean;
isSelected?: boolean;
- onToggleSelection?: (localId: number, isShiftKey?: boolean) => void;
+ onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
}) {
const isSystem = isSystemMessage(message.localType)
const isEmoji = message.localType === 47
@@ -5960,7 +5981,7 @@ function MessageBubble({
onClick={(e) => {
if (isSelectionMode) {
e.stopPropagation()
- onToggleSelection?.(message.localId, e.shiftKey)
+ onToggleSelection?.(messageKey, e.shiftKey)
}
}}
>
@@ -7121,7 +7142,7 @@ function MessageBubble({
onClick={(e) => {
if (isSelectionMode) {
e.stopPropagation()
- onToggleSelection?.(message.localId, e.shiftKey)
+ onToggleSelection?.(messageKey, e.shiftKey)
}
}}
>
diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts
index 164fa5c..691ae57 100644
--- a/src/stores/chatStore.ts
+++ b/src/stores/chatStore.ts
@@ -81,10 +81,9 @@ export const useChatStore = create((set, get) => ({
setMessages: (messages) => set({ messages }),
appendMessages: (newMessages, prepend = false) => set((state) => {
- // 强制去重逻辑
const getMsgKey = (m: Message) => {
- if (m.localId && m.localId > 0) return `l:${m.localId}`
- return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
+ if (m.messageKey) return m.messageKey
+ return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
}
const currentMessages = state.messages || []
const existingKeys = new Set(currentMessages.map(getMsgKey))
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index efe7735..4875413 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -183,12 +183,14 @@ export interface ElectronAPI {
success: boolean;
messages?: Message[];
hasMore?: boolean;
+ nextOffset?: number;
error?: string
}>
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
success: boolean
messages?: Message[]
hasMore?: boolean
+ nextOffset?: number
error?: string
}>
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
diff --git a/src/types/models.ts b/src/types/models.ts
index 7a154f1..0af87b1 100644
--- a/src/types/models.ts
+++ b/src/types/models.ts
@@ -41,6 +41,7 @@ export interface ContactInfo {
// 消息
export interface Message {
+ messageKey: string
localId: number
serverId: number
localType: number
@@ -105,6 +106,7 @@ export interface Message {
// 聊天记录
chatRecordTitle?: string // 聊天记录标题
chatRecordList?: ChatRecordItem[] // 聊天记录列表
+ _db_path?: string
}
// 聊天记录项
From 2f25fd12395397906a9c424144c6d8766fa5c8eb Mon Sep 17 00:00:00 2001
From: hicccc77 <98377878+hicccc77@users.noreply.github.com>
Date: Sun, 15 Mar 2026 19:08:52 +0800
Subject: [PATCH 07/39] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=81=8A?=
=?UTF-8?q?=E5=A4=A9=E6=B6=88=E6=81=AF=E5=85=B3=E9=94=AE=E8=AF=8D=E6=90=9C?=
=?UTF-8?q?=E7=B4=A2=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- wcdbCore: 绑定 wcdb_search_messages DLL 函数,添加 searchMessages 方法
- wcdbWorker: 添加 searchMessages case
- wcdbService: 添加 searchMessages 代理方法
- chatService: 添加 searchMessages,结果解析为 Message 对象
- main: 注册 chat:searchMessages IPC handler
---
electron/main.ts | 4 ++++
electron/services/chatService.ts | 14 ++++++++++++
electron/services/wcdbCore.ts | 38 ++++++++++++++++++++++++++++++++
electron/services/wcdbService.ts | 4 ++++
electron/wcdbWorker.ts | 3 +++
5 files changed, 63 insertions(+)
diff --git a/electron/main.ts b/electron/main.ts
index d636dd5..076c16d 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -1563,6 +1563,10 @@ function registerIpcHandlers() {
return chatService.getMessageById(sessionId, localId)
})
+ ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => {
+ return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
+ })
+
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
return chatService.execQuery(kind, path, sql)
})
diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts
index 0b81fe2..aa958e6 100644
--- a/electron/services/chatService.ts
+++ b/electron/services/chatService.ts
@@ -6580,6 +6580,20 @@ class ChatService {
}
}
+ async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
+ try {
+ const result = await wcdbService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
+ if (!result.success || !result.messages) {
+ return { success: false, error: result.error || '搜索失败' }
+ }
+ const messages = result.messages.map((row: any) => this.parseMessage(row)).filter(Boolean) as Message[]
+ return { success: true, messages }
+ } catch (e) {
+ console.error('ChatService: searchMessages 失败:', e)
+ return { success: false, error: String(e) }
+ }
+ }
+
private parseMessage(row: any): Message {
const rawContent = this.decodeMessageContent(
this.getRowField(row, [
diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts
index 517fedf..a9b99dd 100644
--- a/electron/services/wcdbCore.ts
+++ b/electron/services/wcdbCore.ts
@@ -106,6 +106,7 @@ export class WcdbCore {
private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
+ private wcdbSearchMessages: any = null
private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null
private wcdbInstallSnsBlockDeleteTrigger: any = null
@@ -817,6 +818,13 @@ export class WcdbCore {
this.wcdbGetVoiceData = null
}
+ // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
+ try {
+ this.wcdbSearchMessages = this.lib.func('int32 wcdb_search_messages(int64 handle, const char* sessionId, const char* keyword, int32 limit, int32 offset, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)')
+ } catch {
+ this.wcdbSearchMessages = null
+ }
+
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
try {
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
@@ -2279,6 +2287,36 @@ export class WcdbCore {
})
}
+ async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
+ if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
+ if (!this.wcdbSearchMessages) return { success: false, error: '当前 DLL 版本不支持搜索消息' }
+ try {
+ const handle = this.handle
+ await new Promise(resolve => setImmediate(resolve))
+ if (handle === null || this.handle !== handle) return { success: false, error: '连接已断开' }
+ const outPtr = [null as any]
+ const result = this.wcdbSearchMessages(
+ handle,
+ sessionId || '',
+ keyword,
+ limit || 50,
+ offset || 0,
+ beginTimestamp || 0,
+ endTimestamp || 0,
+ outPtr
+ )
+ if (result !== 0 || !outPtr[0]) {
+ return { success: false, error: `搜索消息失败: ${result}` }
+ }
+ const jsonStr = this.decodeJsonPtr(outPtr[0])
+ if (!jsonStr) return { success: false, error: '解析搜索结果失败' }
+ const messages = JSON.parse(jsonStr)
+ return { success: true, messages }
+ } catch (e) {
+ return { success: false, error: String(e) }
+ }
+ }
+
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts
index 286ddae..b5fcb24 100644
--- a/electron/services/wcdbService.ts
+++ b/electron/services/wcdbService.ts
@@ -406,6 +406,10 @@ export class WcdbService {
return this.callWorker('getMessageById', { sessionId, localId })
}
+ async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
+ return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
+ }
+
/**
* 获取语音数据
*/
diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts
index 8a49cad..5d02904 100644
--- a/electron/wcdbWorker.ts
+++ b/electron/wcdbWorker.ts
@@ -140,6 +140,9 @@ if (parentPort) {
case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId)
break
+ case 'searchMessages':
+ result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
+ break
case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) {
From a800c71cba85dd4485ac5944018fcdae9363e957 Mon Sep 17 00:00:00 2001
From: hicccc77 <98377878+hicccc77@users.noreply.github.com>
Date: Sun, 15 Mar 2026 19:17:14 +0800
Subject: [PATCH 08/39] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20wcdb=5Fapi?=
=?UTF-8?q?=20=E4=BA=8C=E8=BF=9B=E5=88=B6=EF=BC=8C=E6=94=AF=E6=8C=81=20sea?=
=?UTF-8?q?rchMessages=20=E6=8E=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
resources/libwcdb_api.dylib | Bin 0 -> 868720 bytes
resources/wcdb_api.dll | Bin 826880 -> 839680 bytes
2 files changed, 0 insertions(+), 0 deletions(-)
create mode 100755 resources/libwcdb_api.dylib
diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib
new file mode 100755
index 0000000000000000000000000000000000000000..3834eab0362a54b1242dd9b08d6a8cade02eed8b
GIT binary patch
literal 868720
zcmeFad3=@Cwg11*ISHI3C{x00!_d|M)(H|41#?1B12|xab*u?c+W=N&vLdxfh;4&_
z^;Fary(K_Mn*)~e61*h6xn*{Cc2~jIyI)}mYdw=%xJjo$~-rx84`~CBK
zk=N_wdG@sS+H0@9_S$Q&y`R^QeDrA#V|<3cZv6W3yEACa4=Q^r$_(Upx8Io3(vnNB
zFR@W4{@a>*(r_}d`gM0xOG_8tdiTQ4tX<*NWjSdpmZjJig{ijlFUeGP287Rog^Ok`
z=!ohHZ|t)UoS|npSrkr$7j)s#cKcUaI{Wt73rp|#*0&bix)88k;gxK0;3c{F6;6aF
zx+VUVmVSHItkPTWo^|V;3riQynAsKH^)9^JUpl1}PK1{TOYfznv+lTkK_>yb!h7(W
zPCra{yF+0Q*RHT!cncQJpMCqc-Ndf&BDXt$-aXgJpl~1oP*+?_OBdb#-PyN)6Ec45
z4)S${_ws)@@UD0JL*cG462M!&%Y`dTOD7eS6qFWTebsb_`#gRNxw*T9g`@ZD#XJ3Q
zM<>-z4R4xDHv_!Hc4|xvu%pAN@3Z}IB7W0ebpSOphV-X!B0MLy^FQ#umDmwo+k5^S
z4!n?CMB&ear|}x$=fH6b*+-fa2zhZQhCP~jeNtNb%^3@4czI6^uah=upNm&%>C6S4
zqxG}!2KXF&YCEeu0bW8E*c@tS5x>&XD<)qx_0ma`9quqD%8B_vm_BQo1^nz?Hg%krrV^>SYrkg
zP(8EHxbPbz{1?5s{JZbeKT|$@$A!cV<>!Fxi7@VI-__41`Nv74cv$=u=1+1^^c1Yb
zYFs*8S2$_nx!;;U<2$#0_m25<&-?nV^B2rU9-VjLxfh&!LH2n{rXYWUQ_jzW6ZicZ
z5zx?znA{hj58AN=YWrg@N!I{oi71x{1o
zGzCsm;4}qJQ{XfOPE+7C1x{1oGzCsm;4}qJQ{XfOPE+7C1x{1oGzCsm;4}qJQ{XfO
zPE+7C1x{1oGzCsm;4}qJQ{exn6gYI<@9Ga+?W;e x2B+JyH9Ov8adupxMJWnPVm
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