Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
ef3d220521 chore(deps): bump koffi from 2.15.2 to 2.15.5
Bumps [koffi](https://github.com/Koromix/koffi) from 2.15.2 to 2.15.5.
- [Commits](https://github.com/Koromix/koffi/commits)

---
updated-dependencies:
- dependency-name: koffi
  dependency-version: 2.15.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 23:18:31 +00:00
H3CoF6
e8aaae5616 Merge pull request #656 from H3CoF6/main
delete wayland notice
2026-04-07 03:44:19 +08:00
H3CoF6
45deb99e3d delete wayland notice 2026-04-07 03:37:11 +08:00
H3CoF6
b821d370f9 Merge pull request #655 from FATFATHAO/feat/linux-notification
[#654] fix: 更改linux中的消息通知走D-bus总线
2026-04-07 03:10:50 +08:00
fatfathao
60248b28f8 fix: 更改linux中的消息通知走D-bus总线 2026-04-07 01:30:26 +08:00
cc
d128bedffa 新增资源管理并修复了朋友圈的资源缓存路径 2026-04-06 23:32:59 +08:00
26 changed files with 4765 additions and 302 deletions

View File

@@ -4,7 +4,7 @@ import { Worker } from 'worker_threads'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' import { readFile, writeFile, mkdir, rm, readdir, copyFile } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { ConfigService } from './services/config' import { ConfigService } from './services/config'
import { dbPathService } from './services/dbPathService' import { dbPathService } from './services/dbPathService'
@@ -27,7 +27,7 @@ import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService' import { cloudControlService } from './services/cloudControlService'
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow'
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService' import { messagePushService } from './services/messagePushService'
import { bizService } from './services/bizService' import { bizService } from './services/bizService'
@@ -740,6 +740,14 @@ function createWindow(options: { autoShow?: boolean } = {}) {
win.webContents.send('navigate-to-session', sessionId) win.webContents.send('navigate-to-session', sessionId)
}) })
// 设置用于D-Bus通知的Linux通知导航处理程序
setNotificationNavigateHandler((sessionId: string) => {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
win.webContents.send('navigate-to-session', sessionId)
})
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权 // 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
session.defaultSession.webRequest.onBeforeSendHeaders( session.defaultSession.webRequest.onBeforeSendHeaders(
{ {
@@ -1371,6 +1379,225 @@ const removeMatchedEntriesInDir = async (
} }
} }
const normalizeFsPathForCompare = (value: string): string => {
const normalized = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
type SnsCacheMigrationCandidate = {
label: string
sourceDir: string
targetDir: string
fileCount: number
}
type SnsCacheMigrationPlan = {
legacyBaseDir: string
currentBaseDir: string
candidates: SnsCacheMigrationCandidate[]
totalFiles: number
}
type SnsCacheMigrationProgressPayload = {
status: 'running' | 'done' | 'error'
phase: 'copying' | 'cleanup' | 'done' | 'error'
current: number
total: number
copied: number
skipped: number
remaining: number
message?: string
currentItemLabel?: string
}
let snsCacheMigrationInProgress = false
const countFilesInDir = async (dirPath: string): Promise<number> => {
if (!dirPath || !existsSync(dirPath)) return 0
try {
const entries = await readdir(dirPath, { withFileTypes: true })
let count = 0
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
count += await countFilesInDir(fullPath)
continue
}
if (entry.isFile()) count += 1
}
return count
} catch {
return 0
}
}
const migrateDirectoryPreserveNewFiles = async (
sourceDir: string,
targetDir: string,
onFileProcessed?: (payload: { copied: boolean }) => void
): Promise<{ copied: number; skipped: number; processed: number }> => {
let copied = 0
let skipped = 0
let processed = 0
if (!existsSync(sourceDir)) return { copied, skipped, processed }
await mkdir(targetDir, { recursive: true })
const entries = await readdir(sourceDir, { withFileTypes: true })
for (const entry of entries) {
const sourcePath = join(sourceDir, entry.name)
const targetPath = join(targetDir, entry.name)
if (entry.isDirectory()) {
const nested = await migrateDirectoryPreserveNewFiles(sourcePath, targetPath, onFileProcessed)
copied += nested.copied
skipped += nested.skipped
processed += nested.processed
continue
}
if (!entry.isFile()) continue
if (existsSync(targetPath)) {
skipped += 1
processed += 1
onFileProcessed?.({ copied: false })
continue
}
await mkdir(dirname(targetPath), { recursive: true })
await copyFile(sourcePath, targetPath)
copied += 1
processed += 1
onFileProcessed?.({ copied: true })
}
return { copied, skipped, processed }
}
const collectLegacySnsCacheMigrationPlan = async (): Promise<SnsCacheMigrationPlan | null> => {
if (!configService) return null
const legacyBaseDir = configService.getCacheBasePath()
const configuredCachePath = String(configService.get('cachePath') || '').trim()
const currentBaseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
if (!legacyBaseDir || !currentBaseDir) return null
const candidates = [
{
label: '朋友圈媒体缓存',
sourceDir: join(legacyBaseDir, 'sns_cache'),
targetDir: join(currentBaseDir, 'sns_cache')
},
{
label: '朋友圈表情缓存(合并到 Emojis',
sourceDir: join(legacyBaseDir, 'sns_emoji_cache'),
targetDir: join(currentBaseDir, 'Emojis')
},
{
label: '朋友圈表情缓存(当前目录残留)',
sourceDir: join(currentBaseDir, 'sns_emoji_cache'),
targetDir: join(currentBaseDir, 'Emojis')
}
]
const pendingKeys = new Set<string>()
const pending: SnsCacheMigrationCandidate[] = []
for (const item of candidates) {
const sourceKey = normalizeFsPathForCompare(item.sourceDir)
const targetKey = normalizeFsPathForCompare(item.targetDir)
if (!sourceKey || sourceKey === targetKey) continue
const dedupeKey = `${sourceKey}=>${targetKey}`
if (pendingKeys.has(dedupeKey)) continue
const fileCount = await countFilesInDir(item.sourceDir)
if (fileCount <= 0) continue
pendingKeys.add(dedupeKey)
pending.push({ ...item, fileCount })
}
if (pending.length === 0) return null
const totalFiles = pending.reduce((sum, item) => sum + item.fileCount, 0)
return {
legacyBaseDir,
currentBaseDir,
candidates: pending,
totalFiles
}
}
const runLegacySnsCacheMigration = async (
plan: SnsCacheMigrationPlan,
onProgress: (payload: SnsCacheMigrationProgressPayload) => void
): Promise<{ copied: number; skipped: number; totalFiles: number }> => {
let processed = 0
let copied = 0
let skipped = 0
const total = plan.totalFiles
const emitProgress = (patch?: Partial<SnsCacheMigrationProgressPayload>) => {
onProgress({
status: 'running',
phase: 'copying',
current: processed,
total,
copied,
skipped,
remaining: Math.max(0, total - processed),
...patch
})
}
emitProgress({ message: '准备迁移缓存...' })
for (const item of plan.candidates) {
emitProgress({ currentItemLabel: item.label, message: `正在迁移:${item.label}` })
const result = await migrateDirectoryPreserveNewFiles(item.sourceDir, item.targetDir, ({ copied: copiedThisFile }) => {
processed += 1
if (copiedThisFile) copied += 1
else skipped += 1
emitProgress({ currentItemLabel: item.label })
})
// 兜底对齐计数,防止回调未触发造成偏差
const expectedProcessed = copied + skipped
if (processed !== expectedProcessed) {
processed = expectedProcessed
copied = Math.max(copied, result.copied)
skipped = Math.max(skipped, result.skipped)
emitProgress({ currentItemLabel: item.label })
}
}
emitProgress({ phase: 'cleanup', message: '正在清理旧目录...' })
for (const item of plan.candidates) {
await rm(item.sourceDir, { recursive: true, force: true })
}
if (existsSync(plan.legacyBaseDir)) {
try {
const remaining = await readdir(plan.legacyBaseDir)
if (remaining.length === 0) {
await rm(plan.legacyBaseDir, { recursive: true, force: true })
}
} catch {
// 忽略旧目录清理失败,不影响迁移结果
}
}
onProgress({
status: 'done',
phase: 'done',
current: processed,
total,
copied,
skipped,
remaining: Math.max(0, total - processed),
message: '迁移完成'
})
return { copied, skipped, totalFiles: total }
}
// 注册 IPC 处理器 // 注册 IPC 处理器
function registerIpcHandlers() { function registerIpcHandlers() {
registerNotificationHandlers() registerNotificationHandlers()
@@ -1764,9 +1991,9 @@ function registerIpcHandlers() {
}) })
// 视频相关 // 视频相关
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => {
try { try {
const result = await videoService.getVideoInfo(videoMd5) const result = await videoService.getVideoInfo(videoMd5, options)
return { success: true, ...result } return { success: true, ...result }
} catch (e) { } catch (e) {
return { success: false, error: String(e), exists: false } return { success: false, error: String(e), exists: false }
@@ -2088,6 +2315,28 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => { ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
return chatService.getMessageDateCounts(sessionId) return chatService.getMessageDateCounts(sessionId)
}) })
ipcMain.handle('chat:getResourceMessages', async (_, options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => {
return chatService.getResourceMessages(options)
})
ipcMain.handle('chat:getMediaStream', async (_, options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => {
return wcdbService.getMediaStream(options)
})
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
return chatService.resolveVoiceCache(sessionId, msgId) return chatService.resolveVoiceCache(sessionId, msgId)
}) })
@@ -2222,17 +2471,121 @@ function registerIpcHandlers() {
return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey)
}) })
ipcMain.handle('sns:getCacheMigrationStatus', async () => {
try {
const plan = await collectLegacySnsCacheMigrationPlan()
if (!plan) {
return {
success: true,
needed: false,
inProgress: snsCacheMigrationInProgress,
totalFiles: 0,
items: []
}
}
return {
success: true,
needed: true,
inProgress: snsCacheMigrationInProgress,
totalFiles: plan.totalFiles,
legacyBaseDir: plan.legacyBaseDir,
currentBaseDir: plan.currentBaseDir,
items: plan.candidates
}
} catch (error) {
return { success: false, needed: false, error: String((error as Error)?.message || error || '') }
}
})
ipcMain.handle('sns:startCacheMigration', async (event) => {
if (snsCacheMigrationInProgress) {
return { success: false, error: '迁移任务正在进行中' }
}
const sender = event.sender
let lastProgress: SnsCacheMigrationProgressPayload = {
status: 'running',
phase: 'copying',
current: 0,
total: 0,
copied: 0,
skipped: 0,
remaining: 0
}
const emitProgress = (payload: SnsCacheMigrationProgressPayload) => {
lastProgress = payload
if (!sender.isDestroyed()) {
sender.send('sns:cacheMigrationProgress', payload)
}
}
try {
const plan = await collectLegacySnsCacheMigrationPlan()
if (!plan) {
emitProgress({
status: 'done',
phase: 'done',
current: 0,
total: 0,
copied: 0,
skipped: 0,
remaining: 0,
message: '无需迁移'
})
return { success: true, copied: 0, skipped: 0, totalFiles: 0, message: '无需迁移' }
}
snsCacheMigrationInProgress = true
const result = await runLegacySnsCacheMigration(plan, emitProgress)
return { success: true, ...result }
} catch (error) {
const message = String((error as Error)?.message || error || '')
emitProgress({
...lastProgress,
status: 'error',
phase: 'error',
message
})
return { success: false, error: message }
} finally {
snsCacheMigrationInProgress = false
}
})
// 私聊克隆 // 私聊克隆
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload) return imageDecryptService.decryptImage(payload)
}) })
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => { ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => {
return imageDecryptService.resolveCachedImage(payload) return imageDecryptService.resolveCachedImage(payload)
}) })
ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => { ipcMain.handle(
imagePreloadService.enqueue(payloads || []) 'image:resolveCacheBatch',
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
) => {
const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({
...payload,
disableUpdateCheck: options?.disableUpdateCheck === true
})
}))
return { success: true, rows }
}
)
ipcMain.handle(
'image:preload',
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
) => {
imagePreloadService.enqueue(payloads || [], options)
return true return true
}) })

View File

@@ -226,6 +226,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
getResourceMessages: (options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
getMediaStream: (options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getMediaStream', options),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => { onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
@@ -250,10 +266,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload), ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) =>
ipcRenderer.invoke('image:resolveCache', payload), ipcRenderer.invoke('image:resolveCache', payload),
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => resolveCacheBatch: (
ipcRenderer.invoke('image:preload', payloads), payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener) ipcRenderer.on('image:updateAvailable', listener)
@@ -263,12 +285,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload) const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
ipcRenderer.on('image:cacheResolved', listener) ipcRenderer.on('image:cacheResolved', listener)
return () => ipcRenderer.removeListener('image:cacheResolved', listener) return () => ipcRenderer.removeListener('image:cacheResolved', listener)
},
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => void) => {
const listener = (_: unknown, payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
} }
}, },
// 视频 // 视频
video: { video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
}, },
@@ -418,7 +461,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'), uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'), checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId), deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
onCacheMigrationProgress: (callback: (payload: any) => void) => {
const listener = (_event: unknown, payload: any) => callback(payload)
ipcRenderer.on('sns:cacheMigrationProgress', listener)
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
}
}, },
biz: { biz: {

View File

@@ -142,6 +142,14 @@ export interface Message {
_db_path?: string // 内部字段:记录消息所属数据库路径 _db_path?: string // 内部字段:记录消息所属数据库路径
} }
type ResourceMessageType = 'image' | 'video' | 'voice' | 'file'
interface ResourceMessageItem extends Message {
sessionId: string
sessionDisplayName?: string
resourceType: ResourceMessageType
}
export interface Contact { export interface Contact {
username: string username: string
alias: string alias: string
@@ -7544,6 +7552,152 @@ class ChatService {
} }
} }
private resolveResourceType(message: Message): ResourceMessageType | null {
if (message.localType === 3) return 'image'
if (message.localType === 43) return 'video'
if (message.localType === 34) return 'voice'
if (
message.localType === 49 ||
message.localType === 34359738417 ||
message.localType === 103079215153 ||
message.localType === 25769803825
) {
if (message.appMsgKind === 'file' || message.xmlType === '6') return 'file'
if (message.localType !== 49) return 'file'
}
return null
}
async getResourceMessages(options?: {
sessionId?: string
types?: ResourceMessageType[]
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: ResourceMessageItem[]
total?: number
hasMore?: boolean
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const requestedTypes = Array.isArray(options?.types)
? options.types.filter((type): type is ResourceMessageType => ['image', 'video', 'voice', 'file'].includes(type))
: []
const typeSet = new Set<ResourceMessageType>(requestedTypes.length > 0 ? requestedTypes : ['image', 'video', 'voice', 'file'])
const beginTimestamp = Number(options?.beginTimestamp || 0)
const endTimestamp = Number(options?.endTimestamp || 0)
const offset = Math.max(0, Number(options?.offset || 0))
const limitRaw = Number(options?.limit || 0)
const limit = Number.isFinite(limitRaw) ? Math.min(2000, Math.max(1, Math.floor(limitRaw || 300))) : 300
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) {
return { success: false, error: sessionsResult.error || '获取会话失败' }
}
const sessionNameMap = new Map<string, string>()
sessionsResult.sessions.forEach((session) => {
sessionNameMap.set(session.username, session.displayName || session.username)
})
const requestedSessionId = String(options?.sessionId || '').trim()
const sortedSessions = [...sessionsResult.sessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const targetSessionIds = requestedSessionId
? [requestedSessionId]
: sortedSessions.map((session) => session.username)
const localTypes: number[] = []
if (typeSet.has('image')) localTypes.push(3)
if (typeSet.has('video')) localTypes.push(43)
if (typeSet.has('voice')) localTypes.push(34)
if (typeSet.has('file')) {
localTypes.push(49, 34359738417, 103079215153, 25769803825)
}
const uniqueLocalTypes = Array.from(new Set(localTypes))
const allItems: ResourceMessageItem[] = []
const dedup = new Set<string>()
const targetCount = offset + limit
const candidateBuffer = Math.max(180, limit)
const perTypeFetch = requestedSessionId
? Math.min(2000, Math.max(200, targetCount * 2))
: (beginTimestamp > 0 || endTimestamp > 0 ? 140 : 90)
const maxSessionScan = requestedSessionId
? 1
: (beginTimestamp > 0 || endTimestamp > 0 ? 240 : 80)
const scanSessionIds = targetSessionIds.slice(0, maxSessionScan)
let maybeHasMore = targetSessionIds.length > scanSessionIds.length
let stopEarly = false
for (const sessionId of scanSessionIds) {
const batchRows = await Promise.all(
uniqueLocalTypes.map((localType) =>
wcdbService.getMessagesByType(sessionId, localType, false, perTypeFetch, 0)
)
)
for (const result of batchRows) {
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue
if (result.rows.length >= perTypeFetch) maybeHasMore = true
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
for (const message of mapped) {
const resourceType = this.resolveResourceType(message)
if (!resourceType || !typeSet.has(resourceType)) continue
if (beginTimestamp > 0 && message.createTime < beginTimestamp) continue
if (endTimestamp > 0 && message.createTime > endTimestamp) continue
const dedupKey = `${sessionId}:${message.localId}:${message.serverId}:${message.createTime}:${message.localType}`
if (dedup.has(dedupKey)) continue
dedup.add(dedupKey)
allItems.push({
...message,
sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
resourceType
})
}
}
if (allItems.length >= targetCount + candidateBuffer) {
stopEarly = true
maybeHasMore = true
break
}
}
allItems.sort((a, b) => {
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
if (timeDiff !== 0) return timeDiff
return (b.localId || 0) - (a.localId || 0)
})
const total = allItems.length
const start = Math.min(offset, total)
const end = Math.min(start + limit, total)
return {
success: true,
items: allItems.slice(start, end),
total,
hasMore: end < total || maybeHasMore || stopEarly
}
} catch (e) {
console.error('[ChatService] 获取资源消息失败:', e)
return { success: false, error: String(e) }
}
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
try { try {
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()

View File

@@ -55,11 +55,14 @@ type DecryptResult = {
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
} }
type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
type CachedImagePayload = { type CachedImagePayload = {
sessionId?: string sessionId?: string
imageMd5?: string imageMd5?: string
imageDatName?: string imageDatName?: string
preferFilePath?: boolean preferFilePath?: boolean
disableUpdateCheck?: boolean
} }
type DecryptImagePayload = CachedImagePayload & { type DecryptImagePayload = CachedImagePayload & {
@@ -126,7 +129,9 @@ export class ImageDecryptService {
const isThumb = this.isThumbnailPath(cached) const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) { if (isThumb) {
this.triggerUpdateCheck(payload, key, cached) if (!payload.disableUpdateCheck) {
this.triggerUpdateCheck(payload, key, cached)
}
} else { } else {
this.updateFlags.delete(key) this.updateFlags.delete(key)
} }
@@ -146,7 +151,9 @@ export class ImageDecryptService {
const isThumb = this.isThumbnailPath(existing) const isThumb = this.isThumbnailPath(existing)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) { if (isThumb) {
this.triggerUpdateCheck(payload, key, existing) if (!payload.disableUpdateCheck) {
this.triggerUpdateCheck(payload, key, existing)
}
} else { } else {
this.updateFlags.delete(key) this.updateFlags.delete(key)
} }
@@ -167,6 +174,7 @@ export class ImageDecryptService {
if (!cacheKey) { if (!cacheKey) {
return { success: false, error: '缺少图片标识' } return { success: false, error: '缺少图片标识' }
} }
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
if (payload.force) { if (payload.force) {
for (const key of cacheKeys) { for (const key of cacheKeys) {
@@ -176,6 +184,7 @@ export class ImageDecryptService {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath } return { success: true, localPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isImageFile(cached)) {
@@ -191,6 +200,7 @@ export class ImageDecryptService {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath } return { success: true, localPath }
} }
} }
@@ -201,6 +211,7 @@ export class ImageDecryptService {
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isImageFile(cached)) {
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath } return { success: true, localPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isImageFile(cached)) {
@@ -209,7 +220,10 @@ export class ImageDecryptService {
} }
const pending = this.pending.get(cacheKey) const pending = this.pending.get(cacheKey)
if (pending) return pending if (pending) {
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
return pending
}
const task = this.decryptImageInternal(payload, cacheKey) const task = this.decryptImageInternal(payload, cacheKey)
this.pending.set(cacheKey, task) this.pending.set(cacheKey, task)
@@ -261,49 +275,93 @@ export class ImageDecryptService {
cacheKey: string cacheKey: string
): Promise<DecryptResult> { ): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
try { try {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) { if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
return { success: false, error: '未配置账号或数据库路径' } return { success: false, error: '未配置账号或数据库路径' }
} }
const accountDir = this.resolveAccountDir(dbPath, wxid) const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) { if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid }) this.logError('未找到账号目录', undefined, { dbPath, wxid })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
return { success: false, error: '未找到账号目录' } return { success: false, error: '未找到账号目录' }
} }
const datPath = await this.resolveDatPath( let datPath: string | null = null
accountDir, let usedHdAttempt = false
payload.imageMd5, let fallbackToThumbnail = false
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: !payload.force,
skipResolvedCache: Boolean(payload.force),
hardlinkOnly: payload.hardlinkOnly === true
}
)
// 如果要求高清图但没找到,直接返回提示 // force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。
if (!datPath && payload.force) { if (payload.force) {
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) usedHdAttempt = true
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: false,
skipResolvedCache: true,
hardlinkOnly: payload.hardlinkOnly === true
}
)
if (!datPath) {
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: true,
skipResolvedCache: true,
hardlinkOnly: payload.hardlinkOnly === true
}
)
fallbackToThumbnail = Boolean(datPath)
if (fallbackToThumbnail) {
this.logInfo('高清缺失,回退解密缩略图', {
md5: payload.imageMd5,
datName: payload.imageDatName
})
}
}
} else {
datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true
}
)
} }
if (!datPath) { if (!datPath) {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
if (usedHdAttempt) {
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
}
return { success: false, error: '未找到图片文件' } return { success: false, error: '未找到图片文件' }
} }
this.logInfo('找到DAT文件', { datPath }) this.logInfo('找到DAT文件', { datPath })
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
if (!extname(datPath).toLowerCase().includes('dat')) { if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath) const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(datPath) const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath)) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} }
@@ -319,6 +377,7 @@ export class ImageDecryptService {
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing) const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} }
} }
@@ -340,6 +399,7 @@ export class ImageDecryptService {
} }
} }
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
return { success: false, error: '未配置图片解密密钥' } return { success: false, error: '未配置图片解密密钥' }
} }
@@ -347,7 +407,9 @@ export class ImageDecryptService {
const aesKey = this.resolveAesKey(aesKeyRaw) const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
const wxgfResult = await this.unwrapWxgf(decrypted) const wxgfResult = await this.unwrapWxgf(decrypted)
@@ -363,10 +425,12 @@ export class ImageDecryptService {
const finalExt = ext || '.jpg' const finalExt = ext || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
await writeFile(outputPath, decrypted) await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length }) this.logInfo('解密成功', { outputPath, size: decrypted.length })
if (finalExt === '.hevc') { if (finalExt === '.hevc') {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
return { return {
success: false, success: false,
error: '此图片为微信新格式(wxgf)ffmpeg 转换失败,请检查日志', error: '此图片为微信新格式(wxgf)ffmpeg 转换失败,请检查日志',
@@ -378,15 +442,19 @@ export class ImageDecryptService {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) { if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
} else {
this.triggerUpdateCheck(payload, cacheKey, outputPath)
} }
const localPath = payload.preferFilePath const localPath = payload.preferFilePath
? outputPath ? outputPath
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath)) : (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath) const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, emitPath) this.emitCacheResolved(payload, cacheKey, emitPath)
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} catch (e) { } catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
@@ -1288,6 +1356,31 @@ export class ImageDecryptService {
} }
} }
private emitDecryptProgress(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string,
stage: DecryptProgressStage,
progress: number,
status: 'running' | 'done' | 'error',
message?: string
): void {
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
const event = {
cacheKey,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
stage,
progress: safeProgress,
status,
message: message || ''
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('image:decryptProgress', event)
}
}
}
private async ensureCacheIndexed(): Promise<void> { private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing if (this.cacheIndexing) return this.cacheIndexing

View File

@@ -6,36 +6,57 @@ type PreloadImagePayload = {
imageDatName?: string imageDatName?: string
} }
type PreloadOptions = {
allowDecrypt?: boolean
}
type PreloadTask = PreloadImagePayload & { type PreloadTask = PreloadImagePayload & {
key: string key: string
allowDecrypt: boolean
} }
export class ImagePreloadService { export class ImagePreloadService {
private queue: PreloadTask[] = [] private queue: PreloadTask[] = []
private pending = new Set<string>() private pending = new Set<string>()
private active = 0 private activeCache = 0
private readonly maxConcurrent = 2 private activeDecrypt = 0
private readonly maxCacheConcurrent = 8
private readonly maxDecryptConcurrent = 2
private readonly maxQueueSize = 320
enqueue(payloads: PreloadImagePayload[]): void { enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false
for (const payload of payloads) { for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) continue if (!cacheKey) continue
const key = `${payload.sessionId || 'unknown'}|${cacheKey}` const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue if (this.pending.has(key)) continue
this.pending.add(key) this.pending.add(key)
this.queue.push({ ...payload, key }) this.queue.push({ ...payload, key, allowDecrypt })
} }
this.processQueue() this.processQueue()
} }
private processQueue(): void { private processQueue(): void {
while (this.active < this.maxConcurrent && this.queue.length > 0) { while (this.queue.length > 0) {
const task = this.queue.shift() const taskIndex = this.queue.findIndex((task) => (
task.allowDecrypt
? this.activeDecrypt < this.maxDecryptConcurrent
: this.activeCache < this.maxCacheConcurrent
))
if (taskIndex < 0) return
const task = this.queue.splice(taskIndex, 1)[0]
if (!task) return if (!task) return
this.active += 1
if (task.allowDecrypt) this.activeDecrypt += 1
else this.activeCache += 1
void this.handleTask(task).finally(() => { void this.handleTask(task).finally(() => {
this.active -= 1 if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
else this.activeCache = Math.max(0, this.activeCache - 1)
this.pending.delete(task.key) this.pending.delete(task.key)
this.processQueue() this.processQueue()
}) })
@@ -49,9 +70,11 @@ export class ImagePreloadService {
const cached = await imageDecryptService.resolveCachedImage({ const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,
imageDatName: task.imageDatName imageDatName: task.imageDatName,
disableUpdateCheck: !task.allowDecrypt
}) })
if (cached.success) return if (cached.success) return
if (!task.allowDecrypt) return
await imageDecryptService.decryptImage({ await imageDecryptService.decryptImage({
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,

View File

@@ -17,9 +17,9 @@ export class KeyServiceLinux {
constructor() { constructor() {
try { try {
this.sudo = require('sudo-prompt'); this.sudo = require('@vscode/sudo-prompt');
} catch (e) { } catch (e) {
console.error('Failed to load sudo-prompt', e); console.error('Failed to load @vscode/sudo-prompt', e);
} }
} }
@@ -361,4 +361,4 @@ export class KeyServiceLinux {
return { ciphertext, xorKey } return { ciphertext, xorKey }
} }
} }

View File

@@ -0,0 +1,344 @@
import dbus from "dbus-native";
import https from "https";
import http, { IncomingMessage } from "http";
import { promises as fs } from "fs";
import { join } from "path";
import { app } from "electron";
const BUS_NAME = "org.freedesktop.Notifications";
const OBJECT_PATH = "/org/freedesktop/Notifications";
export interface LinuxNotificationData {
sessionId?: string;
title: string;
content: string;
avatarUrl?: string;
expireTimeout?: number;
}
type NotificationCallback = (sessionId: string) => void;
let sessionBus: dbus.DBusConnection | null = null;
let notificationCallbacks: NotificationCallback[] = [];
let pendingNotifications: Map<number, LinuxNotificationData> = new Map();
// 头像缓存url->localFilePath
const avatarCache: Map<string, string> = new Map();
// 缓存目录
let avatarCacheDir: string | null = null;
async function getSessionBus(): Promise<dbus.DBusConnection> {
if (!sessionBus) {
sessionBus = dbus.sessionBus();
// 挂载底层socket的error事件防止掉线即可
sessionBus.connection.on("error", (err: Error) => {
console.error("[LinuxNotification] D-Bus connection error:", err);
sessionBus = null; // 报错清理死对象
});
}
return sessionBus;
}
// 确保缓存目录存在
async function ensureCacheDir(): Promise<string> {
if (!avatarCacheDir) {
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
try {
await fs.mkdir(avatarCacheDir, { recursive: true });
} catch (error) {
console.error(
"[LinuxNotification] Failed to create avatar cache dir:",
error,
);
}
}
return avatarCacheDir;
}
// 下载头像到本地临时文件
async function downloadAvatarToLocal(url: string): Promise<string | null> {
// 检查缓存
if (avatarCache.has(url)) {
return avatarCache.get(url) || null;
}
try {
const cacheDir = await ensureCacheDir();
// 生成唯一文件名
const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`;
const localPath = join(cacheDir, fileName);
await new Promise<void>((resolve, reject) => {
// 微信 CDN 需要特殊的请求头才能下载图片
const options = {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
Referer: "https://servicewechat.com/",
Accept:
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
Connection: "keep-alive",
},
};
const callback = (res: IncomingMessage) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
await fs.writeFile(localPath, buffer);
avatarCache.set(url, localPath);
resolve();
} catch (err) {
reject(err);
}
});
res.on("error", reject);
};
const req = url.startsWith("https")
? https.get(url, options, callback)
: http.get(url, options, callback);
req.on("error", reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error("Download timeout"));
});
});
console.log(
`[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`,
);
return localPath;
} catch (error) {
console.error("[LinuxNotification] Failed to download avatar:", error);
return null;
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
): Promise<number | null> {
try {
const bus = await getSessionBus();
const appName = "WeFlow";
const replaceId = 0;
const expireTimeout = data.expireTimeout ?? 5000;
// 处理头像下载到本地或使用URL
let appIcon = "";
let hints: any[] = [];
if (data.avatarUrl) {
// 优先尝试下载到本地
const localPath = await downloadAvatarToLocal(data.avatarUrl);
if (localPath) {
hints = [["image-path", ["s", localPath]]];
}
}
return new Promise((resolve, reject) => {
bus.invoke(
{
destination: BUS_NAME,
path: OBJECT_PATH,
interface: "org.freedesktop.Notifications",
member: "Notify",
signature: "susssasa{sv}i",
body: [
appName,
replaceId,
appIcon,
data.title,
data.content,
["default", "打开"], // 提供default action否则系统不会抛出点击事件
hints,
// [], // 传空数组以避开a{sv}变体的序列化崩溃有pendingNotifications映射维护保证不出错
expireTimeout,
],
},
(err: Error | null, result: any) => {
if (err) {
console.error("[LinuxNotification] Notify error:", err);
reject(err);
return;
}
const notificationId =
typeof result === "number" ? result : result[0];
if (data.sessionId) {
// 依赖Map实现点击追踪没有使用D-Bus hints
pendingNotifications.set(notificationId, data);
}
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}, icon: ${appIcon || "none"}`,
);
resolve(notificationId);
},
);
});
} catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error);
return null;
}
}
export async function closeLinuxNotification(
notificationId: number,
): Promise<void> {
try {
const bus = await getSessionBus();
return new Promise((resolve, reject) => {
bus.invoke(
{
destination: BUS_NAME,
path: OBJECT_PATH,
interface: "org.freedesktop.Notifications",
member: "CloseNotification",
signature: "u",
body: [notificationId],
},
(err: Error | null) => {
if (err) {
console.error("[LinuxNotification] CloseNotification error:", err);
reject(err);
return;
}
pendingNotifications.delete(notificationId);
resolve();
},
);
});
} catch (error) {
console.error("[LinuxNotification] Failed to close notification:", error);
}
}
export async function getCapabilities(): Promise<string[]> {
try {
const bus = await getSessionBus();
return new Promise((resolve, reject) => {
bus.invoke(
{
destination: BUS_NAME,
path: OBJECT_PATH,
interface: "org.freedesktop.Notifications",
member: "GetCapabilities",
},
(err: Error | null, result: any) => {
if (err) {
console.error("[LinuxNotification] GetCapabilities error:", err);
reject(err);
return;
}
resolve(result as string[]);
},
);
});
} catch (error) {
console.error("[LinuxNotification] Failed to get capabilities:", error);
return [];
}
}
export function onNotificationAction(callback: NotificationCallback): void {
notificationCallbacks.push(callback);
}
export function removeNotificationCallback(
callback: NotificationCallback,
): void {
const index = notificationCallbacks.indexOf(callback);
if (index > -1) {
notificationCallbacks.splice(index, 1);
}
}
function triggerNotificationCallback(sessionId: string): void {
for (const callback of notificationCallbacks) {
try {
callback(sessionId);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
}
}
}
export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init");
return;
}
try {
const bus = await getSessionBus();
// 监听底层connection的message事件
bus.connection.on("message", (msg: any) => {
// type 4表示SIGNAL
if (
msg.type === 4 &&
msg.path === OBJECT_PATH &&
msg.interface === "org.freedesktop.Notifications"
) {
if (msg.member === "ActionInvoked") {
const [notificationId, actionId] = msg.body;
console.log(
`[LinuxNotification] Action invoked: ${notificationId}, ${actionId}`,
);
// 如果用户点击了通知本体actionId会是'default'
if (actionId === "default") {
const data = pendingNotifications.get(notificationId);
if (data?.sessionId) {
triggerNotificationCallback(data.sessionId);
}
}
}
if (msg.member === "NotificationClosed") {
const [notificationId] = msg.body;
pendingNotifications.delete(notificationId);
}
}
});
// AddMatch用来接收信号
await new Promise<void>((resolve, reject) => {
bus.invoke(
{
destination: "org.freedesktop.DBus",
path: "/org/freedesktop/DBus",
interface: "org.freedesktop.DBus",
member: "AddMatch",
signature: "s",
body: ["type='signal',interface='org.freedesktop.Notifications'"],
},
(err: Error | null) => {
if (err) {
console.error("[LinuxNotification] AddMatch error:", err);
reject(err);
return;
}
resolve();
},
);
});
console.log("[LinuxNotification] Service initialized");
// 打印相关日志
const caps = await getCapabilities();
console.log("[LinuxNotification] Server capabilities:", caps);
} catch (error) {
console.error("[LinuxNotification] Failed to initialize:", error);
}
}

View File

@@ -1,6 +1,7 @@
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { ConfigService } from './config' import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService' import { ContactCacheService } from './contactCacheService'
import { app } from 'electron'
import { existsSync, mkdirSync } from 'fs' import { existsSync, mkdirSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises' import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path' import { basename, join } from 'path'
@@ -801,14 +802,25 @@ class SnsService {
} }
private getSnsCacheDir(): string { private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath() const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
const snsCacheDir = join(cachePath, 'sns_cache') const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
const snsCacheDir = join(baseDir, 'sns_cache')
if (!existsSync(snsCacheDir)) { if (!existsSync(snsCacheDir)) {
mkdirSync(snsCacheDir, { recursive: true }) mkdirSync(snsCacheDir, { recursive: true })
} }
return snsCacheDir return snsCacheDir
} }
private getEmojiCacheDir(): string {
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
const emojiDir = join(baseDir, 'Emojis')
if (!existsSync(emojiDir)) {
mkdirSync(emojiDir, { recursive: true })
}
return emojiDir
}
private getCacheFilePath(url: string): string { private getCacheFilePath(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex') const hash = crypto.createHash('md5').update(url).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg' const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
@@ -1832,7 +1844,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const isVideo = isVideoUrl(url) const isVideo = isVideoUrl(url)
const cachePath = this.getCacheFilePath(url) const cachePath = this.getCacheFilePath(url)
// 1. 尝试从磁盘缓存读取 // 1. 优先尝试从当前缓存目录读取
if (existsSync(cachePath)) { if (existsSync(cachePath)) {
try { try {
// 对于视频,不读取整个文件到内存,只确认存在即可 // 对于视频,不读取整个文件到内存,只确认存在即可
@@ -2293,9 +2305,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const fs = require('fs') const fs = require('fs')
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
const cachePath = this.configService.getCacheBasePath() const emojiDir = this.getEmojiCacheDir()
const emojiDir = join(cachePath, 'sns_emoji_cache')
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
// 检查本地缓存 // 检查本地缓存
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) { for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {

View File

@@ -1,5 +1,8 @@
import { join } from 'path' import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
import { spawn } from 'child_process'
import { pathToFileURL } from 'url'
import crypto from 'crypto'
import { app } from 'electron' import { app } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
@@ -22,15 +25,50 @@ interface VideoIndexEntry {
thumbPath?: string thumbPath?: string
} }
type PosterFormat = 'dataUrl' | 'fileUrl'
function getStaticFfmpegPath(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string') {
let fixedPath = ffmpegStatic
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
}
if (existsSync(fixedPath)) return fixedPath
}
} catch {
// ignore
}
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
if (existsSync(devPath)) return devPath
if (app.isPackaged) {
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
if (existsSync(packedPath)) return packedPath
}
return null
}
class VideoService { class VideoService {
private configService: ConfigService private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>() private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>() private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>() private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>() private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private pendingPosterExtract = new Map<string, Promise<string | null>>()
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
private posterExtractRunning = 0
private posterExtractQueue: Array<() => void> = []
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000 private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
private readonly maxPosterExtractConcurrency = 1
private readonly maxCacheEntries = 2000 private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6 private readonly maxIndexEntries = 6
@@ -256,12 +294,10 @@ class VideoService {
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
} }
/** private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
try { try {
if (!filePath || !existsSync(filePath)) return undefined if (!filePath || !existsSync(filePath)) return undefined
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
const buffer = readFileSync(filePath) const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}` return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch { } catch {
@@ -355,7 +391,12 @@ class VideoService {
return index return index
} }
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null { private getVideoInfoFromIndex(
index: Map<string, VideoIndexEntry>,
md5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase() const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null if (!normalizedMd5) return null
@@ -379,8 +420,8 @@ class VideoService {
} }
return { return {
videoUrl: entry.videoPath, videoUrl: entry.videoPath,
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'), coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'), thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
exists: true exists: true
} }
} }
@@ -388,7 +429,12 @@ class VideoService {
return null return null
} }
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null { private fallbackScanVideo(
videoBaseDir: string,
realVideoMd5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
try { try {
const yearMonthDirs = readdirSync(videoBaseDir) const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => { .filter((dir) => {
@@ -416,8 +462,8 @@ class VideoService {
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return { return {
videoUrl: videoPath, videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
exists: true exists: true
} }
} }
@@ -427,14 +473,165 @@ class VideoService {
return null return null
} }
private getFfmpegPath(): string {
const staticPath = getStaticFfmpegPath()
if (staticPath) return staticPath
return 'ffmpeg'
}
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
await new Promise<void>((resolve) => {
this.posterExtractQueue.push(resolve)
})
}
this.posterExtractRunning += 1
try {
return await run()
} finally {
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
const next = this.posterExtractQueue.shift()
if (next) next()
}
}
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
const normalizedPath = String(videoPath || '').trim()
if (!normalizedPath || !existsSync(normalizedPath)) return null
const cacheKey = `${normalizedPath}|format=${posterFormat}`
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
if (cached !== undefined) return cached
const pending = this.pendingPosterExtract.get(cacheKey)
if (pending) return pending
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
try {
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
} catch {
resolve(null)
return
}
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
resolve(pathToFileURL(outputPath).toString())
return
}
const ffmpegPath = this.getFfmpegPath()
const args = [
'-hide_banner', '-loglevel', 'error', '-y',
'-ss', '0',
'-i', normalizedPath,
'-frames:v', '1',
'-q:v', '3',
outputPath
]
const errChunks: Buffer[] = []
let done = false
const finish = (value: string | null) => {
if (done) return
done = true
if (posterFormat === 'dataUrl') {
try {
if (existsSync(outputPath)) unlinkSync(outputPath)
} catch {
// ignore
}
}
resolve(value)
}
const proc = spawn(ffmpegPath, args, {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
const timer = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* ignore */ }
finish(null)
}, 12000)
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('error', () => {
clearTimeout(timer)
finish(null)
})
proc.on('close', (code: number) => {
clearTimeout(timer)
if (code !== 0 || !existsSync(outputPath)) {
if (errChunks.length > 0) {
this.log('extractFirstFrameDataUrl failed', {
videoPath: normalizedPath,
error: Buffer.concat(errChunks).toString().slice(0, 240)
})
}
finish(null)
return
}
try {
const jpgBuf = readFileSync(outputPath)
if (!jpgBuf.length) {
finish(null)
return
}
if (posterFormat === 'fileUrl') {
finish(pathToFileURL(outputPath).toString())
return
}
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
} catch {
finish(null)
}
})
}))
this.pendingPosterExtract.set(cacheKey, task)
try {
const result = await task
this.writeTimedCache(
this.extractedPosterCache,
cacheKey,
result,
this.extractedPosterCacheTtlMs,
this.maxCacheEntries
)
return result
} finally {
this.pendingPosterExtract.delete(cacheKey)
}
}
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
if (!includePoster) return info
if (!info.exists || !info.videoUrl) return info
if (info.coverUrl || info.thumbUrl) return info
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
if (!extracted) return info
return {
...info,
coverUrl: extracted,
thumbUrl: extracted
}
}
/** /**
* 根据视频MD5获取视频文件信息 * 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/ */
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> { async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const includePoster = options?.includePoster !== false const includePoster = options?.includePoster !== false
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
@@ -446,7 +643,7 @@ class VideoService {
} }
const scopeKey = this.getScopeKey(dbPath, wxid) const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}` const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo if (cachedInfo) return cachedInfo
@@ -465,16 +662,18 @@ class VideoService {
} }
const index = this.getOrBuildVideoIndex(videoBaseDir) const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster) const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
if (indexed) { if (indexed) {
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries) const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
return indexed this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
} }
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster) const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
if (fallback) { if (fallback) {
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries) const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
return fallback this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
} }
const miss = { exists: false } const miss = { exists: false }

View File

@@ -1,6 +1,7 @@
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import * as fzstd from 'fzstd'
//数据服务初始化错误信息,用于帮助用户诊断问题 //数据服务初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null let lastDllInitError: string | null = null
@@ -80,6 +81,7 @@ export class WcdbCore {
private wcdbGetSessionMessageDateCounts: any = null private wcdbGetSessionMessageDateCounts: any = null
private wcdbGetSessionMessageDateCountsBatch: any = null private wcdbGetSessionMessageDateCountsBatch: any = null
private wcdbGetMessagesByType: any = null private wcdbGetMessagesByType: any = null
private wcdbScanMediaStream: any = null
private wcdbGetHeadImageBuffers: any = null private wcdbGetHeadImageBuffers: any = null
private wcdbSearchMessages: any = null private wcdbSearchMessages: any = null
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
@@ -1013,6 +1015,11 @@ export class WcdbCore {
} catch { } catch {
this.wcdbGetMessagesByType = null this.wcdbGetMessagesByType = null
} }
try {
this.wcdbScanMediaStream = this.lib.func('int32 wcdb_scan_media_stream(int64 handle, const char* sessionIdsJson, int32 mediaType, int32 beginTimestamp, int32 endTimestamp, int32 limit, int32 offset, _Out_ void** outJson, _Out_ int32* outHasMore)')
} catch {
this.wcdbScanMediaStream = null
}
try { try {
this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)') this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch { } catch {
@@ -1921,6 +1928,397 @@ export class WcdbCore {
} }
} }
async getMediaStream(options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' }
try {
const toInt = (value: unknown): number => {
const n = Number(value || 0)
if (!Number.isFinite(n)) return 0
return Math.floor(n)
}
const pickString = (row: Record<string, any>, keys: string[]): string => {
for (const key of keys) {
const value = row[key]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
const extractXmlValue = (xml: string, tag: string): string => {
if (!xml) return ''
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
const match = regex.exec(xml)
if (!match) return ''
return String(match[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
const looksLikeHex = (text: string): boolean => {
if (!text || text.length < 2 || text.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(text)
}
const looksLikeBase64 = (text: string): boolean => {
if (!text || text.length < 16 || text.length % 4 !== 0) return false
return /^[A-Za-z0-9+/]+={0,2}$/.test(text)
}
const decodeBinaryContent = (data: Buffer, fallbackValue?: string): string => {
if (!data || data.length === 0) return ''
try {
if (data.length >= 4) {
const magicLE = data.readUInt32LE(0)
const magicBE = data.readUInt32BE(0)
if (magicLE === 0xFD2FB528 || magicBE === 0xFD2FB528) {
try {
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
} catch {
// ignore
}
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
if (fallbackValue && replacementCount > 0) return fallbackValue
return data.toString('latin1')
} catch {
return fallbackValue || ''
}
}
const decodeMaybeCompressed = (raw: unknown): string => {
if (raw === null || raw === undefined) return ''
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return decodeBinaryContent(Buffer.from(raw as any), String(raw))
}
const text = String(raw).trim()
if (!text) return ''
if (text.length > 16 && looksLikeHex(text)) {
try {
const bytes = Buffer.from(text, 'hex')
if (bytes.length > 0) return decodeBinaryContent(bytes, text)
} catch {
// ignore
}
}
if (text.length > 16 && looksLikeBase64(text)) {
try {
const bytes = Buffer.from(text, 'base64')
if (bytes.length > 0) return decodeBinaryContent(bytes, text)
} catch {
// ignore
}
}
return text
}
const decodeMessageContent = (messageContent: unknown, compressContent: unknown): string => {
const compressedDecoded = decodeMaybeCompressed(compressContent)
if (compressedDecoded) return compressedDecoded
return decodeMaybeCompressed(messageContent)
}
const extractImageMd5 = (xml: string): string => {
const byTag = extractXmlValue(xml, 'md5') || extractXmlValue(xml, 'imgmd5')
if (byTag) return byTag
const byAttr = /(?:md5|imgmd5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml)
return byAttr?.[1] || ''
}
const normalizeDatBase = (value: string): string => {
const input = String(value || '').trim()
if (!input) return ''
const fileBase = input.replace(/^.*[\\/]/, '').replace(/\.(?:t\.)?dat$/i, '')
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
}
const decodePackedToPrintable = (raw: string): string => {
const text = String(raw || '').trim()
if (!text) return ''
let buf: Buffer | null = null
if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) {
try {
buf = Buffer.from(text, 'hex')
} catch {
buf = null
}
}
if (!buf) {
try {
const base64 = Buffer.from(text, 'base64')
if (base64.length > 0) buf = base64
} catch {
buf = null
}
}
if (!buf || buf.length === 0) return ''
const printable: number[] = []
for (const byte of buf) {
if (byte >= 0x20 && byte <= 0x7e) printable.push(byte)
else printable.push(0x20)
}
return Buffer.from(printable).toString('utf-8')
}
const extractHexMd5 = (text: string): string => {
const input = String(text || '')
if (!input) return ''
const match = /([a-fA-F0-9]{32})/.exec(input)
return String(match?.[1] || '').toLowerCase()
}
const extractImageDatName = (row: Record<string, any>, content: string): string => {
const direct = pickString(row, [
'image_path',
'imagePath',
'image_dat_name',
'imageDatName',
'img_path',
'imgPath',
'img_name',
'imgName'
])
const normalizedDirect = normalizeDatBase(direct)
if (normalizedDirect) return normalizedDirect
const xmlCandidate = extractXmlValue(content, 'imgname') || extractXmlValue(content, 'cdnmidimgurl')
const normalizedXml = normalizeDatBase(xmlCandidate)
if (normalizedXml) return normalizedXml
const packedRaw = pickString(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
const packedText = decodePackedToPrintable(packedRaw)
if (packedText) {
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(packedText)
if (datLike?.[1]) return String(datLike[1]).toLowerCase()
const md5Like = /([0-9a-fA-F]{16,64})/.exec(packedText)
if (md5Like?.[1]) return String(md5Like[1]).toLowerCase()
}
return ''
}
const extractPackedPayload = (row: Record<string, any>): string => {
const packedRaw = pickString(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
return decodePackedToPrintable(packedRaw)
}
const extractVideoMd5 = (xml: string): string => {
const byTag =
extractXmlValue(xml, 'rawmd5') ||
extractXmlValue(xml, 'videomd5') ||
extractXmlValue(xml, 'newmd5') ||
extractXmlValue(xml, 'md5')
if (byTag) return byTag
const byAttr = /(?:rawmd5|videomd5|newmd5|md5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml)
return byAttr?.[1] || ''
}
const requestedSessionId = String(options?.sessionId || '').trim()
const mediaType = String(options?.mediaType || 'all').trim() as 'image' | 'video' | 'all'
const beginTimestamp = Math.max(0, toInt(options?.beginTimestamp))
const endTimestamp = Math.max(0, toInt(options?.endTimestamp))
const offset = Math.max(0, toInt(options?.offset))
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
const sessionsRes = await this.getSessions()
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
return { success: false, error: sessionsRes.error || '读取会话失败' }
}
const sessions = (sessionsRes.sessions || [])
.map((row: any) => ({
sessionId: String(
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const sessionRows = requestedSessionId
? sessions.filter((row) => row.sessionId === requestedSessionId)
: sessions
if (sessionRows.length === 0) {
return { success: true, items: [], hasMore: false, nextOffset: offset }
}
const sessionNameMap = new Map(sessionRows.map((row) => [row.sessionId, row.displayName || row.sessionId]))
const outPtr = [null as any]
const outHasMore = [0]
const mediaTypeCode = mediaType === 'image' ? 1 : mediaType === 'video' ? 2 : 0
const result = this.wcdbScanMediaStream(
this.handle,
JSON.stringify(sessionRows.map((row) => row.sessionId)),
mediaTypeCode,
beginTimestamp,
endTimestamp,
limit,
offset,
outPtr,
outHasMore
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `扫描媒体流失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体流失败' }
const rows = JSON.parse(jsonStr)
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
let items = list.map((row) => {
const sessionId = pickString(row, ['session_id', 'sessionId']) || requestedSessionId
const localType = toInt(row.local_type ?? row.localType)
const rawMessageContent = pickString(row, [
'message_content',
'messageContent',
'message_content_text',
'messageText',
'StrContent',
'str_content',
'msg_content',
'msgContent',
'strContent',
'content',
'rawContent',
'WCDB_CT_message_content'
])
const rawCompressContent = pickString(row, [
'compress_content',
'compressContent',
'msg_compress_content',
'msgCompressContent',
'WCDB_CT_compress_content'
])
const useRawMessageContent = Boolean(
rawMessageContent &&
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
)
const content = useRawMessageContent
? rawMessageContent
: decodeMessageContent(rawMessageContent, rawCompressContent)
const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const imageMd5 = localType === 3
? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined)
: undefined
const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const videoMd5 = localType === 43
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined)
: undefined
return {
sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
mediaType: localType === 43 ? 'video' as const : 'image' as const,
localId: toInt(row.local_id ?? row.localId),
serverId: pickString(row, ['server_id', 'serverId']) || undefined,
createTime: toInt(row.create_time ?? row.createTime),
localType,
senderUsername: pickString(row, ['sender_username', 'senderUsername']) || undefined,
isSend: row.is_send === null || row.is_send === undefined ? null : toInt(row.is_send),
imageMd5,
imageDatName,
videoMd5,
content: content || undefined
}
})
const unresolvedSessionIds = Array.from(
new Set(
items
.map((item) => item.sessionId)
.filter((sessionId) => {
const name = String(sessionNameMap.get(sessionId) || '').trim()
return !name || name === sessionId
})
)
)
if (unresolvedSessionIds.length > 0) {
const displayNameRes = await this.getDisplayNames(unresolvedSessionIds)
if (displayNameRes.success && displayNameRes.map) {
unresolvedSessionIds.forEach((sessionId) => {
const display = String(displayNameRes.map?.[sessionId] || '').trim()
if (display) sessionNameMap.set(sessionId, display)
})
items = items.map((item) => ({
...item,
sessionDisplayName: sessionNameMap.get(item.sessionId) || item.sessionId
}))
}
}
return {
success: true,
items,
hasMore: Number(outHasMore[0]) > 0,
nextOffset: offset + items.length
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> { async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }

View File

@@ -268,6 +268,37 @@ export class WcdbService {
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset }) return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
} }
async getMediaStream(options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}> {
return this.callWorker('getMediaStream', { options })
}
/** /**
* 获取联系人昵称 * 获取联系人昵称
*/ */

18
electron/types/dbus.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
declare module 'dbus-native' {
namespace dbus {
interface DBusConnection {
invoke(options: any, callback: (err: Error | null, result?: any) => void): void;
on(event: string, listener: Function): void;
// 底层connection用于监听signal
connection: {
on(event: string, listener: Function): void;
};
}
// 声明sessionBus方法
function sessionBus(): DBusConnection;
function systemBus(): DBusConnection;
}
export = dbus;
}

View File

@@ -80,6 +80,9 @@ if (parentPort) {
case 'getMessagesByType': case 'getMessagesByType':
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset) result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
break break
case 'getMediaStream':
result = await core.getMediaStream(payload.options)
break
case 'getDisplayNames': case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames) result = await core.getDisplayNames(payload.usernames)
break break

View File

@@ -1,224 +1,333 @@
import { BrowserWindow, ipcMain, screen } from 'electron' import { BrowserWindow, ipcMain, screen } from "electron";
import { join } from 'path' import { join } from "path";
import { ConfigService } from '../services/config' import { ConfigService } from "../services/config";
let notificationWindow: BrowserWindow | null = null // Linux D-Bus通知服务
let closeTimer: NodeJS.Timeout | null = null const isLinux = process.platform === "linux";
let linuxNotificationService:
| typeof import("../services/linuxNotificationService")
| null = null;
// 用于处理通知点击的回调函数在Linux上用于导航到会话
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
export function setNotificationNavigateHandler(
callback: (sessionId: string) => void,
) {
onNotificationNavigate = callback;
}
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
export function destroyNotificationWindow() { export function destroyNotificationWindow() {
if (closeTimer) { if (closeTimer) {
clearTimeout(closeTimer) clearTimeout(closeTimer);
closeTimer = null closeTimer = null;
} }
lastNotificationData = null lastNotificationData = null;
if (!notificationWindow || notificationWindow.isDestroyed()) { if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null notificationWindow = null;
return return;
} }
const win = notificationWindow const win = notificationWindow;
notificationWindow = null notificationWindow = null;
try { try {
win.destroy() win.destroy();
} catch (error) { } catch (error) {
console.warn('[NotificationWindow] Failed to destroy window:', error) console.warn("[NotificationWindow] Failed to destroy window:", error);
} }
} }
export function createNotificationWindow() { export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) { if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow return notificationWindow;
} }
const isDev = !!process.env.VITE_DEV_SERVER_URL const isDev = !!process.env.VITE_DEV_SERVER_URL;
const iconPath = isDev const iconPath = isDev
? join(__dirname, '../../public/icon.ico') ? join(__dirname, "../../public/icon.ico")
: join(process.resourcesPath, 'icon.ico') : join(process.resourcesPath, "icon.ico");
console.log('[NotificationWindow] Creating window...') console.log("[NotificationWindow] Creating window...");
const width = 344 const width = 344;
const height = 114 const height = 114;
// Update default creation size // Update default creation size
notificationWindow = new BrowserWindow({ notificationWindow = new BrowserWindow({
width: width, width: width,
height: height, height: height,
type: 'toolbar', // 有助于在某些操作系统上保持置顶 type: "toolbar", // 有助于在某些操作系统上保持置顶
frame: false, frame: false,
transparent: true, transparent: true,
resizable: false, resizable: false,
show: false, show: false,
alwaysOnTop: true, alwaysOnTop: true,
skipTaskbar: true, skipTaskbar: true,
focusable: false, // 不抢占焦点 focusable: false, // 不抢占焦点
icon: iconPath, icon: iconPath,
webPreferences: { webPreferences: {
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist) preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
// devTools: true // Enable DevTools // devTools: true // Enable DevTools
} },
}) });
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools // notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透 notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?) // 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
// 实际上,我们希望窗口可点击。 // 实际上,我们希望窗口可点击。
// 我们将在显示时将忽略鼠标事件设为 false。 // 我们将在显示时将忽略鼠标事件设为 false。
const loadUrl = isDev const loadUrl = isDev
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window` ? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
: `file://${join(__dirname, '../dist/index.html')}#/notification-window` : `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
console.log('[NotificationWindow] Loading URL:', loadUrl) console.log("[NotificationWindow] Loading URL:", loadUrl);
notificationWindow.loadURL(loadUrl) notificationWindow.loadURL(loadUrl);
notificationWindow.on('closed', () => { notificationWindow.on("closed", () => {
notificationWindow = null notificationWindow = null;
}) });
return notificationWindow return notificationWindow;
} }
export async function showNotification(data: any) { export async function showNotification(data: any) {
// 先检查配置 // 先检查配置
const config = ConfigService.getInstance() const config = ConfigService.getInstance();
const enabled = await config.get('notificationEnabled') const enabled = await config.get("notificationEnabled");
if (enabled === false) return // 默认为 true if (enabled === false) return; // 默认为 true
// 检查会话过滤 // 检查会话过滤
const filterMode = config.get('notificationFilterMode') || 'all' const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get('notificationFilterList') || [] const filterList = config.get("notificationFilterList") || [];
const sessionId = data.sessionId const sessionId = data.sessionId;
if (sessionId && filterMode !== 'all' && filterList.length > 0) { if (sessionId && filterMode !== "all" && filterList.length > 0) {
const isInList = filterList.includes(sessionId) const isInList = filterList.includes(sessionId);
if (filterMode === 'whitelist' && !isInList) { if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示 // 白名单模式:不在列表中则不显示
return return;
}
if (filterMode === 'blacklist' && isInList) {
// 黑名单模式:在列表中则不显示
return
}
} }
if (filterMode === "blacklist" && isInList) {
let win = notificationWindow // 黑名单模式:在列表中则不显示
if (!win || win.isDestroyed()) { return;
win = createNotificationWindow()
} }
}
if (!win) return // Linux 使用 D-Bus 通知
if (isLinux) {
await showLinuxNotification(data);
return;
}
// 确保加载完成 let win = notificationWindow;
if (win.webContents.isLoading()) { if (!win || win.isDestroyed()) {
win.once('ready-to-show', () => { win = createNotificationWindow();
showAndSend(win!, data) }
})
} else { if (!win) return;
showAndSend(win, data)
} // 确保加载完成
if (win.webContents.isLoading()) {
win.once("ready-to-show", () => {
showAndSend(win!, data);
});
} else {
showAndSend(win, data);
}
} }
let lastNotificationData: any = null // 显示Linux通知
async function showLinuxNotification(data: any) {
if (!linuxNotificationService) {
try {
linuxNotificationService =
await import("../services/linuxNotificationService");
} catch (error) {
console.error(
"[NotificationWindow] Failed to load Linux notification service:",
error,
);
return;
}
}
const { showLinuxNotification: showNotification } = linuxNotificationService;
const notificationData = {
title: data.title,
content: data.content,
avatarUrl: data.avatarUrl,
sessionId: data.sessionId,
expireTimeout: 5000,
};
showNotification(notificationData);
}
let lastNotificationData: any = null;
async function showAndSend(win: BrowserWindow, data: any) { async function showAndSend(win: BrowserWindow, data: any) {
lastNotificationData = data lastNotificationData = data;
const config = ConfigService.getInstance() const config = ConfigService.getInstance();
const position = (await config.get('notificationPosition')) || 'top-right' const position = (await config.get("notificationPosition")) || "top-right";
// 更新位置 // 更新位置
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize const { width: screenWidth, height: screenHeight } =
const winWidth = position === 'top-center' ? 280 : 344 screen.getPrimaryDisplay().workAreaSize;
const winHeight = 114 const winWidth = position === "top-center" ? 280 : 344;
const padding = 20 const winHeight = 114;
const padding = 20;
let x = 0 let x = 0;
let y = 0 let y = 0;
switch (position) { switch (position) {
case 'top-center': case "top-center":
x = (screenWidth - winWidth) / 2 x = (screenWidth - winWidth) / 2;
y = padding y = padding;
break break;
case 'top-right': case "top-right":
x = screenWidth - winWidth - padding x = screenWidth - winWidth - padding;
y = padding y = padding;
break break;
case 'bottom-right': case "bottom-right":
x = screenWidth - winWidth - padding x = screenWidth - winWidth - padding;
y = screenHeight - winHeight - padding y = screenHeight - winHeight - padding;
break break;
case 'top-left': case "top-left":
x = padding x = padding;
y = padding y = padding;
break break;
case 'bottom-left': case "bottom-left":
x = padding x = padding;
y = screenHeight - winHeight - padding y = screenHeight - winHeight - padding;
break break;
}
win.setPosition(Math.floor(x), Math.floor(y));
win.setSize(winWidth, winHeight); // 确保尺寸
// 设为可交互
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) {
try {
const linuxNotificationModule =
await import("../services/linuxNotificationService");
linuxNotificationService = linuxNotificationModule;
// 初始化服务
await linuxNotificationModule.initLinuxNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((sessionId: string) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
sessionId,
);
// 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。
if (onNotificationNavigate) {
onNotificationNavigate(sessionId);
} else {
// 如果尚未设置处理程序则通过ipcMain发出事件
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
console.warn(
"[NotificationWindow] onNotificationNavigate not set yet",
);
}
});
console.log(
"[NotificationWindow] Linux notification service initialized",
);
} catch (error) {
console.error(
"[NotificationWindow] Failed to initialize Linux notification service:",
error,
);
} }
}
win.setPosition(Math.floor(x), Math.floor(y)) ipcMain.handle("notification:show", (_, data) => {
win.setSize(winWidth, winHeight) // 确保尺寸 showNotification(data);
});
// 设为可交互 ipcMain.handle("notification:close", () => {
win.setIgnoreMouseEvents(false) if (isLinux && linuxNotificationService) {
win.showInactive() // 显示但不聚焦 // 注册通知点击回调函数。Linux通知通过D-Bus自动关闭但我们可以根据需要进行跟踪
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级 return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
});
win.webContents.send('notification:show', { ...data, position }) // Handle renderer ready event (fix race condition)
ipcMain.on("notification:ready", (event) => {
if (isLinux) {
// Linux不需要通知窗口拦截通知窗口渲染
return;
}
console.log("[NotificationWindow] Renderer ready, checking cached data");
if (
lastNotificationData &&
notificationWindow &&
!notificationWindow.isDestroyed()
) {
console.log("[NotificationWindow] Re-sending cached data");
notificationWindow.webContents.send(
"notification:show",
lastNotificationData,
);
}
});
// 自动关闭计时器通常由渲染进程管理 // Handle resize request from renderer
// 渲染进程发送 'notification:close' 来隐藏窗口 ipcMain.on("notification:resize", (event, { width, height }) => {
} if (isLinux) {
// Linux 通知通过D-Bus自动调整大小
export function registerNotificationHandlers() { return;
ipcMain.handle('notification:show', (_, data) => { }
showNotification(data) if (notificationWindow && !notificationWindow.isDestroyed()) {
}) // Enforce max-height if needed, or trust renderer
// Ensure it doesn't go off screen bottom?
ipcMain.handle('notification:close', () => { // Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
if (notificationWindow && !notificationWindow.isDestroyed()) { // If we resize, we should re-calculate position to keep it anchored?
notificationWindow.hide() // Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // If bottom-right, growing down pushes it off screen.
}
}) // Simple version: just setSize. For V1 we assume Top-Right.
// But wait, the config supports bottom-right.
// Handle renderer ready event (fix race condition) // We can re-call setPosition or just let it be.
ipcMain.on('notification:ready', (event) => { // If bottom-right, y needs to prevent overflow.
console.log('[NotificationWindow] Renderer ready, checking cached data')
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) { // Ideally we get current config position
console.log('[NotificationWindow] Re-sending cached data') const bounds = notificationWindow.getBounds();
notificationWindow.webContents.send('notification:show', lastNotificationData) // 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));
}
// Handle resize request from renderer });
ipcMain.on('notification:resize', (event, { width, height }) => {
if (notificationWindow && !notificationWindow.isDestroyed()) { // 'notification-clicked' 在 main.ts 中处理 (导航)
// Enforce max-height if needed, or trust renderer
// Ensure it doesn't go off screen bottom?
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
// If we resize, we should re-calculate position to keep it anchored?
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
// If bottom-right, growing down pushes it off screen.
// Simple version: just setSize. For V1 we assume Top-Right.
// But wait, the config supports bottom-right.
// 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))
}
})
// 'notification-clicked' 在 main.ts 中处理 (导航)
} }

232
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "4.3.0", "version": "4.3.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2",
"dbus-native": "^0.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -19,7 +21,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.5",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -29,7 +31,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
@@ -3050,6 +3051,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vscode/sudo-prompt": {
"version": "9.3.2",
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz",
"integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.12", "version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
@@ -3077,6 +3084,25 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/abstract-socket": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"dependencies": {
"bindings": "^1.2.1",
"nan": "^2.12.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -3589,6 +3615,16 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -4423,6 +4459,27 @@
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dbus-native": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.4.0.tgz",
"integrity": "sha512-i3zvY3tdPEOaMgmK4riwupjDYRJ53rcE1Kj8rAgnLOFmBd0DekUih59qv8v+Oyils/U9p+s4sSsaBzHWLztI+Q==",
"license": "MIT",
"dependencies": {
"event-stream": "^4.0.0",
"hexy": "^0.2.10",
"long": "^4.0.0",
"optimist": "^0.6.1",
"put": "0.0.6",
"safe-buffer": "^5.1.1",
"xml2js": "^0.4.17"
},
"bin": {
"dbus2js": "bin/dbus2js.js"
},
"optionalDependencies": {
"abstract-socket": "^2.0.0"
}
},
"node_modules/debounce-fn": { "node_modules/debounce-fn": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
@@ -4791,6 +4848,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"license": "MIT"
},
"node_modules/duplexer2": { "node_modules/duplexer2": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -5316,6 +5379,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/event-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
"integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==",
"license": "MIT",
"dependencies": {
"duplexer": "^0.1.1",
"from": "^0.1.7",
"map-stream": "0.0.7",
"pause-stream": "^0.0.11",
"split": "^1.0.1",
"stream-combiner": "^0.2.2",
"through": "^2.3.8"
}
},
"node_modules/exceljs": { "node_modules/exceljs": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -5492,6 +5570,13 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -5579,6 +5664,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==",
"license": "MIT"
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -5978,6 +6069,15 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hexy": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==",
"license": "MIT",
"bin": {
"hexy": "bin/hexy_cmd.js"
}
},
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -6531,9 +6631,9 @@
} }
}, },
"node_modules/koffi": { "node_modules/koffi": {
"version": "2.15.2", "version": "2.15.5",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.5.tgz",
"integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==", "integrity": "sha512-4/35/oOpnH9tzrpWAC3ObjAERBSe0Q0Dh2NP1eBBPRGpohEj4vFw2+7tej9W9MTExvk0vtF0PjMqIGG4rf6feQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -6706,6 +6806,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -6768,6 +6874,12 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/map-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
"integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==",
"license": "MIT"
},
"node_modules/markdown-table": { "node_modules/markdown-table": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -7911,6 +8023,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -8103,6 +8222,22 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/optimist": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
"integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==",
"license": "MIT/X11",
"dependencies": {
"minimist": "~0.0.1",
"wordwrap": "~0.0.2"
}
},
"node_modules/optimist/node_modules/minimist": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz",
"integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==",
"license": "MIT"
},
"node_modules/ora": { "node_modules/ora": {
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
@@ -8252,6 +8387,18 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
"license": [
"MIT",
"Apache2"
],
"dependencies": {
"through": "~2.3"
}
},
"node_modules/pe-library": { "node_modules/pe-library": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
@@ -8450,6 +8597,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/put": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/put/-/put-0.0.6.tgz",
"integrity": "sha512-w0szIZ2NkqznMFqxYPRETCIi+q/S8UKis9F4yOl6/N9NDCZmbjZZT85aI4FgJf3vIPrzMPX60+odCLOaYxNWWw==",
"license": "MIT/X11",
"engines": {
"node": ">=0.3.0"
}
},
"node_modules/quick-lru": { "node_modules/quick-lru": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@@ -9311,6 +9467,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/split": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
"integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==",
"license": "MIT",
"dependencies": {
"through": "2"
},
"engines": {
"node": "*"
}
},
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -9342,6 +9510,16 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/stream-combiner": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz",
"integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==",
"license": "MIT",
"dependencies": {
"duplexer": "~0.1.1",
"through": "~2.3.4"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -9456,13 +9634,6 @@
"inline-style-parser": "0.2.7" "inline-style-parser": "0.2.7"
} }
}, },
"node_modules/sudo-prompt": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/sumchecker": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -9617,6 +9788,12 @@
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
}, },
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"license": "MIT"
},
"node_modules/tiny-async-pool": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -10203,6 +10380,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wordwrap": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
"integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -10246,6 +10432,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -23,6 +23,8 @@
"electron:build": "npm run build" "electron:build": "npm run build"
}, },
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2",
"dbus-native": "^0.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -33,7 +35,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.5",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -43,7 +45,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },

Binary file not shown.

View File

@@ -22,6 +22,7 @@ import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage' import SnsPage from './pages/SnsPage'
import BizPage from './pages/BizPage' import BizPage from './pages/BizPage'
import ContactsPage from './pages/ContactsPage' import ContactsPage from './pages/ContactsPage'
import ResourcesPage from './pages/ResourcesPage'
import ChatHistoryPage from './pages/ChatHistoryPage' import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow' import NotificationWindow from './pages/NotificationWindow'
@@ -669,32 +670,32 @@ function App() {
</div> </div>
)} )}
{showWaylandWarning && ( {/*{showWaylandWarning && (*/}
<div className="agreement-overlay"> {/* <div className="agreement-overlay">*/}
<div className="agreement-modal"> {/* <div className="agreement-modal">*/}
<div className="agreement-header"> {/* <div className="agreement-header">*/}
<Shield size={32} /> {/* <Shield size={32} />*/}
<h2> (Wayland)</h2> {/* <h2>环境兼容性提示 (Wayland)</h2>*/}
</div> {/* </div>*/}
<div className="agreement-content"> {/* <div className="agreement-content">*/}
<div className="agreement-text"> {/* <div className="agreement-text">*/}
<p>使 <strong>Wayland</strong> </p> {/* <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>*/}
<p> Wayland <strong></strong></p> {/* <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>*/}
<p></p> {/* <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>*/}
<br /> {/* <br />*/}
<p>使</p> {/* <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>*/}
<p>1. <strong>X11 (Xorg)</strong> </p> {/* <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>*/}
<p>2. (WM/DE) </p> {/* <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
<div className="agreement-footer"> {/* <div className="agreement-footer">*/}
<div className="agreement-actions"> {/* <div className="agreement-actions">*/}
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}></button> {/* <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
)} {/*)}*/}
{/* 更新提示对话框 */} {/* 更新提示对话框 */}
<UpdateDialog <UpdateDialog
@@ -743,6 +744,7 @@ function App() {
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/biz" element={<BizPage />} /> <Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} /> <Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} /> <Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes> </Routes>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react' import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -429,6 +429,16 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 资源浏览 */}
<NavLink
to="/resources"
className={`nav-item ${isActive('/resources') ? 'active' : ''}`}
title={collapsed ? '资源浏览' : undefined}
>
<span className="nav-icon"><FolderClosed size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊天分析 */} {/* 聊天分析 */}
<NavLink <NavLink
to="/analytics" to="/analytics"

View File

@@ -0,0 +1,620 @@
.resources-page.stream-rebuild {
--stream-columns: 4;
--stream-grid-gap: 12px;
--stream-card-width: 272px;
--stream-card-height: 356px;
--stream-visual-height: 236px;
--stream-slot-width: calc(var(--stream-card-width) + var(--stream-grid-gap));
--stream-slot-height: calc(var(--stream-card-height) + var(--stream-grid-gap));
--stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns));
height: calc(100% + 48px);
margin: -24px;
padding: 16px 18px;
position: relative;
background: var(--bg-primary);
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
.stream-toolbar {
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
background: var(--card-bg, #f8f9fb);
border-radius: 16px;
padding: 12px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
flex: 1;
}
.media-tabs {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
border: 1px solid var(--border-color);
button {
border: none;
background: transparent;
color: var(--text-secondary, #5f6674);
border-radius: 9px;
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: color-mix(in srgb, var(--primary) 18%, var(--card-bg));
color: var(--text-primary, #1c2230);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent);
}
}
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
.filter-field {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent);
background: var(--bg-secondary, #f3f5f8);
color: var(--text-secondary, #566074);
border-radius: 10px;
padding: 0 10px;
min-height: 36px;
box-sizing: border-box;
svg {
color: var(--text-tertiary, #8a92a3);
flex: 0 0 auto;
}
}
.filter-select {
min-width: 300px;
}
.filter-date {
min-width: 160px;
}
input,
select {
border: none;
outline: none;
background: transparent;
color: var(--text-primary, #1c2230);
font-size: 13px;
min-width: 0;
height: 34px;
line-height: 34px;
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
appearance: none;
}
.contact-select {
width: 100%;
min-width: 220px;
padding-right: 8px;
}
.date-input {
width: 128px;
min-width: 128px;
}
.sep {
color: var(--text-tertiary);
font-size: 12px;
}
.ghost {
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary, #5f6674);
border-radius: 10px;
height: 36px;
padding: 0 12px;
cursor: pointer;
}
.reset-btn {
border-color: color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent);
background: var(--bg-secondary, #f3f5f8);
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
button {
border: 1px solid var(--border-color);
background: var(--bg-secondary, #f3f5f8);
color: var(--text-secondary, #5f6674);
border-radius: 10px;
height: 34px;
padding: 0 12px;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&:disabled {
opacity: 0.58;
cursor: not-allowed;
}
&.danger {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color));
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--bg-secondary));
}
}
}
.stream-summary {
display: flex;
gap: 14px;
font-size: 12px;
color: var(--text-tertiary);
padding: 0 4px;
flex-wrap: wrap;
}
.stream-state {
height: 120px;
border: 1px dashed var(--border-color);
border-radius: 12px;
background: var(--card-bg);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&.error {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color));
}
}
.stream-grid-wrap {
flex: 1;
min-height: 0;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--card-bg) 94%, transparent);
overflow: hidden;
display: flex;
flex-direction: column;
}
.stream-grid {
flex: 1;
min-height: 0;
height: 100%;
overflow-anchor: none;
}
.stream-grid-list,
.virtuoso-grid-list {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
align-content: flex-start;
padding: 10px 0 2px;
width: var(--stream-grid-width);
min-width: var(--stream-grid-width);
max-width: var(--stream-grid-width);
margin: 0 auto;
}
.stream-grid-item,
.virtuoso-grid-item {
box-sizing: border-box;
width: var(--stream-slot-width);
min-width: var(--stream-slot-width);
max-width: var(--stream-slot-width);
flex: 0 0 var(--stream-slot-width);
height: var(--stream-slot-height);
padding-right: var(--stream-grid-gap);
padding-bottom: var(--stream-grid-gap);
display: flex;
}
.stream-grid-item > *,
.virtuoso-grid-item > * {
width: 100%;
height: 100%;
}
.media-card {
width: 100%;
height: 100%;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 14px;
overflow: hidden;
transition: border-color 0.16s ease;
position: relative;
display: flex;
flex-direction: column;
&:hover {
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
}
&.selected {
border-color: color-mix(in srgb, var(--primary) 56%, var(--border-color));
outline: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
outline-offset: -1px;
}
&.decrypting {
.card-visual {
opacity: 0.68;
}
}
}
.floating-delete {
position: absolute;
top: 10px;
right: 10px;
z-index: 4;
width: 28px;
height: 28px;
border-radius: 9px;
border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-color));
color: var(--danger);
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(-2px) scale(0.96);
pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease;
}
.media-card:hover .floating-delete,
.media-card:focus-within .floating-delete {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.floating-update {
position: absolute;
top: 10px;
left: 10px;
z-index: 4;
border: 1px solid color-mix(in srgb, var(--primary) 45%, var(--border-color));
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
color: var(--text-primary);
border-radius: 9px;
height: 28px;
padding: 0 8px;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.card-visual {
width: 100%;
height: var(--stream-visual-height);
min-height: var(--stream-visual-height);
max-height: var(--stream-visual-height);
border: none;
cursor: pointer;
background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:disabled {
cursor: not-allowed;
}
&.image img,
&.video img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
&.image img.long-image {
object-fit: cover;
object-position: top center;
}
.placeholder {
width: 100%;
min-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary);
padding: 12px;
text-align: center;
span {
font-size: 12px;
color: var(--text-secondary);
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.decrypting-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background: linear-gradient(140deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04));
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.35) 50%, transparent 65%);
animation: decrypt-sheen 1.6s linear infinite;
pointer-events: none;
}
}
.decrypting-spinner {
position: relative;
z-index: 1;
width: 30px;
height: 30px;
border-radius: 999px;
border: 2px solid rgba(15, 23, 42, 0.2);
border-top-color: color-mix(in srgb, var(--primary) 78%, #ffffff);
animation: decrypt-spin 0.85s linear infinite, decrypt-pulse 1.2s ease-in-out infinite;
box-shadow:
0 0 0 8px rgba(255, 255, 255, 0.26),
0 10px 24px rgba(15, 23, 42, 0.12);
}
}
.card-meta {
padding: 9px 10px 8px;
min-height: 66px;
margin-top: auto;
cursor: pointer;
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
transition: background-color 0.15s ease;
&:hover {
background: color-mix(in srgb, var(--bg-secondary) 68%, transparent);
}
}
.title-row,
.sub-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.title-row {
.session {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 62%;
}
.time {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
}
.sub-row {
margin-top: 4px;
font-size: 11px;
color: var(--text-tertiary);
}
.grid-loading-more,
.grid-end {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
.spin {
animation: resources-spin 1s linear infinite;
}
.action-message {
color: color-mix(in srgb, var(--primary) 75%, var(--text-secondary));
font-weight: 600;
}
.resource-dialog-mask {
position: absolute;
inset: 0;
background: rgba(8, 11, 18, 0.24);
display: flex;
align-items: center;
justify-content: center;
z-index: 30;
}
.resource-dialog {
width: min(420px, calc(100% - 32px));
background: var(--card-bg, #ffffff);
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.dialog-header {
padding: 12px 14px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
}
.dialog-body {
padding: 16px 14px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.55;
white-space: pre-wrap;
}
.dialog-actions {
padding: 0 14px 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dialog-btn {
min-width: 72px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-color);
font-size: 13px;
cursor: pointer;
&.ghost {
background: var(--bg-secondary);
color: var(--text-secondary);
}
&.solid {
background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
color: var(--text-primary);
}
}
}
@media (max-width: 900px) {
.resources-page.stream-rebuild {
.stream-toolbar {
flex-direction: column;
}
.toolbar-right {
justify-content: flex-start;
}
.filters {
.filter-select {
min-width: 220px;
}
}
}
}
@media (max-width: 680px) {
.resources-page.stream-rebuild {
--stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns));
.stream-grid-list,
.virtuoso-grid-list {
margin: 0;
padding-left: 0;
padding-right: 0;
}
}
}
@keyframes resources-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes decrypt-sheen {
from {
transform: translateX(-45%);
}
to {
transform: translateX(45%);
}
}
@keyframes decrypt-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes decrypt-pulse {
0%,
100% {
opacity: 0.92;
}
50% {
opacity: 0.68;
}
}

1265
src/pages/ResourcesPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1642,6 +1642,202 @@
} }
} }
.sns-cache-migration-dialog {
background: var(--bg-secondary);
border-radius: var(--sns-border-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
width: 540px;
max-width: 92vw;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.sns-cache-migration-close {
position: absolute;
right: 12px;
top: 12px;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
&:hover:not(:disabled) {
background: var(--bg-primary);
color: var(--text-primary);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.sns-cache-migration-header {
padding: 18px 20px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
}
.sns-cache-migration-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.sns-cache-migration-subtitle {
margin-top: 6px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.sns-cache-migration-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sns-cache-migration-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 12px;
strong {
font-size: 16px;
color: var(--text-primary);
}
}
.sns-cache-migration-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.sns-cache-migration-progress-bar {
width: 100%;
height: 8px;
border-radius: 999px;
background: var(--bg-tertiary);
overflow: hidden;
}
.sns-cache-migration-progress-fill {
height: 100%;
background: linear-gradient(90deg, #34d399, #10b981);
transition: width 0.2s ease;
}
.sns-cache-migration-progress-text {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.sns-cache-migration-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
}
.sns-cache-migration-item {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-tertiary);
padding: 8px 10px;
}
.sns-cache-migration-item-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.sns-cache-migration-item-detail {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
line-height: 1.45;
}
.sns-cache-migration-error,
.sns-cache-migration-success {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
padding: 8px 10px;
border-radius: 8px;
}
.sns-cache-migration-error {
background: rgba(244, 67, 54, 0.1);
color: var(--color-error, #f44336);
}
.sns-cache-migration-success {
background: rgba(76, 175, 80, 0.1);
color: var(--color-success, #4caf50);
}
.sns-cache-migration-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 0 20px 18px;
}
.sns-cache-migration-btn {
min-width: 110px;
height: 38px;
border-radius: 9px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&.primary {
background: var(--primary, #576b95);
color: #fff;
}
&.secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border-color: var(--border-color);
}
&:hover:not(:disabled) {
filter: brightness(1.05);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.author-timeline-dialog { .author-timeline-dialog {
background: var(--sns-card-bg); background: var(--sns-card-bg);
border-radius: var(--sns-border-radius-lg); border-radius: var(--sns-border-radius-lg);

View File

@@ -66,6 +66,33 @@ type OverviewStatsStatus = 'loading' | 'ready' | 'error'
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] } type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
interface SnsCacheMigrationItem {
label: string
sourceDir: string
targetDir: string
fileCount: number
}
interface SnsCacheMigrationStatus {
totalFiles: number
legacyBaseDir?: string
currentBaseDir?: string
items: SnsCacheMigrationItem[]
}
interface SnsCacheMigrationProgress {
status: 'running' | 'done' | 'error'
phase: 'copying' | 'cleanup' | 'done' | 'error'
current: number
total: number
copied: number
skipped: number
remaining: number
message?: string
currentItemLabel?: string
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => { const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try { try {
@@ -162,6 +189,12 @@ export default function SnsPage() {
const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null) const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null)
const [triggerLoading, setTriggerLoading] = useState(false) const [triggerLoading, setTriggerLoading] = useState(false)
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [showCacheMigrationDialog, setShowCacheMigrationDialog] = useState(false)
const [cacheMigrationStatus, setCacheMigrationStatus] = useState<SnsCacheMigrationStatus | null>(null)
const [cacheMigrationProgress, setCacheMigrationProgress] = useState<SnsCacheMigrationProgress | null>(null)
const [cacheMigrationRunning, setCacheMigrationRunning] = useState(false)
const [cacheMigrationDone, setCacheMigrationDone] = useState(false)
const [cacheMigrationError, setCacheMigrationError] = useState<string | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null) const postsContainerRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null) const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
@@ -185,6 +218,7 @@ export default function SnsPage() {
const contactsCountBatchTimerRef = useRef<number | null>(null) const contactsCountBatchTimerRef = useRef<number | null>(null)
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map()) const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
const jumpDateRequestSeqRef = useRef(0) const jumpDateRequestSeqRef = useRef(0)
const checkedCacheMigrationRef = useRef(false)
// Sync posts ref // Sync posts ref
useEffect(() => { useEffect(() => {
@@ -595,6 +629,133 @@ export default function SnsPage() {
} }
}, [persistSnsPageCache]) }, [persistSnsPageCache])
const markCacheMigrationPrompted = useCallback(() => {
try {
window.sessionStorage.setItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY, '1')
} catch {
// ignore session storage failures
}
}, [])
const hasCacheMigrationPrompted = useCallback(() => {
try {
return window.sessionStorage.getItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY) === '1'
} catch {
return false
}
}, [])
const checkCacheMigrationStatus = useCallback(async () => {
if (checkedCacheMigrationRef.current) return
checkedCacheMigrationRef.current = true
if (hasCacheMigrationPrompted()) return
try {
const result = await window.electronAPI.sns.getCacheMigrationStatus()
if (!result?.success || !result.needed) return
const totalFiles = Math.max(0, Number(result.totalFiles || 0))
const items = Array.isArray(result.items)
? result.items.map((item) => ({
label: String(item.label || '').trim(),
sourceDir: String(item.sourceDir || '').trim(),
targetDir: String(item.targetDir || '').trim(),
fileCount: Math.max(0, Number(item.fileCount || 0))
})).filter((item) => item.label && item.sourceDir && item.targetDir && item.fileCount > 0)
: []
if (totalFiles <= 0 || items.length === 0) return
setCacheMigrationStatus({
totalFiles,
legacyBaseDir: result.legacyBaseDir,
currentBaseDir: result.currentBaseDir,
items
})
setCacheMigrationProgress(null)
setCacheMigrationDone(false)
setCacheMigrationError(null)
setShowCacheMigrationDialog(true)
markCacheMigrationPrompted()
} catch (error) {
console.error('Failed to check SNS cache migration status:', error)
}
}, [hasCacheMigrationPrompted, markCacheMigrationPrompted])
const startCacheMigration = useCallback(async () => {
const total = Math.max(0, cacheMigrationStatus?.totalFiles || 0)
setCacheMigrationError(null)
setCacheMigrationDone(false)
setCacheMigrationRunning(true)
setCacheMigrationProgress({
status: 'running',
phase: 'copying',
current: 0,
total,
copied: 0,
skipped: 0,
remaining: total,
message: '准备迁移...'
})
const removeProgress = window.electronAPI.sns.onCacheMigrationProgress((payload) => {
if (!payload) return
setCacheMigrationProgress({
status: payload.status,
phase: payload.phase,
current: Math.max(0, Number(payload.current || 0)),
total: Math.max(0, Number(payload.total || 0)),
copied: Math.max(0, Number(payload.copied || 0)),
skipped: Math.max(0, Number(payload.skipped || 0)),
remaining: Math.max(0, Number(payload.remaining || 0)),
message: payload.message,
currentItemLabel: payload.currentItemLabel
})
if (payload.status === 'done') {
setCacheMigrationDone(true)
setCacheMigrationError(null)
} else if (payload.status === 'error') {
setCacheMigrationError(payload.message || '迁移失败')
}
})
try {
const result = await window.electronAPI.sns.startCacheMigration()
if (!result?.success) {
setCacheMigrationError(result?.error || '迁移失败')
} else {
const totalFiles = Math.max(0, Number(result.totalFiles || 0))
if (totalFiles === 0) {
setCacheMigrationDone(true)
setCacheMigrationProgress({
status: 'done',
phase: 'done',
current: 0,
total: 0,
copied: 0,
skipped: 0,
remaining: 0,
message: result.message || '无需迁移'
})
} else {
// 兜底:若 done 事件因时序原因未到达,仍以返回结果收敛到完成态。
setCacheMigrationDone(true)
setCacheMigrationProgress((prev) => prev || {
status: 'done',
phase: 'done',
current: totalFiles,
total: totalFiles,
copied: Math.max(0, Number(result.copied || 0)),
skipped: Math.max(0, Number(result.skipped || 0)),
remaining: 0,
message: '迁移完成'
})
}
}
} catch (error) {
setCacheMigrationError(String((error as Error)?.message || error || '迁移失败'))
} finally {
removeProgress()
setCacheMigrationRunning(false)
}
}, [cacheMigrationStatus?.totalFiles])
const renderOverviewRangeText = () => { const renderOverviewRangeText = () => {
if (overviewStatsStatus === 'error') { if (overviewStatsStatus === 'error') {
return ( return (
@@ -1256,7 +1417,8 @@ export default function SnsPage() {
void hydrateSnsPageCache() void hydrateSnsPageCache()
loadContacts() loadContacts()
loadOverviewStats() loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats]) void checkCacheMigrationStatus()
}, [checkCacheMigrationStatus, hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => { useEffect(() => {
const syncCurrentUserProfile = async () => { const syncCurrentUserProfile = async () => {
@@ -1659,6 +1821,117 @@ export default function SnsPage() {
</div> </div>
)} )}
{showCacheMigrationDialog && cacheMigrationStatus && (
<div
className="modal-overlay"
onClick={() => {
if (cacheMigrationRunning) return
setShowCacheMigrationDialog(false)
}}
>
<div className="sns-cache-migration-dialog" onClick={(e) => e.stopPropagation()}>
<button
className="close-btn sns-cache-migration-close"
onClick={() => !cacheMigrationRunning && setShowCacheMigrationDialog(false)}
disabled={cacheMigrationRunning}
>
<X size={18} />
</button>
<div className="sns-cache-migration-header">
<div className="sns-cache-migration-title"></div>
<div className="sns-cache-migration-subtitle">
</div>
</div>
<div className="sns-cache-migration-body">
<div className="sns-cache-migration-meta">
<span></span>
<strong>{cacheMigrationStatus.totalFiles}</strong>
</div>
{cacheMigrationProgress && (
<div className="sns-cache-migration-progress">
<div className="sns-cache-migration-progress-bar">
<div
className="sns-cache-migration-progress-fill"
style={{
width: cacheMigrationProgress.total > 0
? `${Math.min(100, Math.round((cacheMigrationProgress.current / cacheMigrationProgress.total) * 100))}%`
: '100%'
}}
/>
</div>
<div className="sns-cache-migration-progress-text">
<span>{cacheMigrationProgress.message || '迁移中...'}</span>
<span>
{cacheMigrationProgress.copied} {cacheMigrationProgress.remaining} {cacheMigrationProgress.skipped}
</span>
</div>
</div>
)}
{!cacheMigrationProgress && (
<div className="sns-cache-migration-items">
{cacheMigrationStatus.items.map((item, idx) => (
<div className="sns-cache-migration-item" key={`${item.label}-${idx}`}>
<div className="sns-cache-migration-item-title">{item.label}</div>
<div className="sns-cache-migration-item-detail">
{item.fileCount} · {item.sourceDir} {item.targetDir}
</div>
</div>
))}
</div>
)}
{cacheMigrationError && (
<div className="sns-cache-migration-error">
<AlertCircle size={14} />
<span>{cacheMigrationError}</span>
</div>
)}
{cacheMigrationDone && !cacheMigrationError && (
<div className="sns-cache-migration-success">
<CheckCircle size={14} />
<span></span>
</div>
)}
</div>
<div className="sns-cache-migration-actions">
{!cacheMigrationDone ? (
<>
<button
className="sns-cache-migration-btn secondary"
onClick={() => setShowCacheMigrationDialog(false)}
disabled={cacheMigrationRunning}
>
</button>
<button
className="sns-cache-migration-btn primary"
onClick={() => { void startCacheMigration() }}
disabled={cacheMigrationRunning}
>
{cacheMigrationRunning ? '迁移中...' : '开始迁移'}
</button>
</>
) : (
<button
className="sns-cache-migration-btn primary"
onClick={() => setShowCacheMigrationDialog(false)}
disabled={cacheMigrationRunning}
>
</button>
)}
</div>
</div>
</div>
)}
{/* 朋友圈防删除插件对话框 */} {/* 朋友圈防删除插件对话框 */}
{showTriggerDialog && ( {showTriggerDialog && (
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}> <div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>

View File

@@ -16,6 +16,7 @@ const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
const dbPathPlaceholder = isMac const dbPathPlaceholder = isMac
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9' ? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
: isLinux : isLinux
@@ -221,10 +222,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (!path) return null if (!path) return null
// 检测中文字符和其他可能有问题的特殊字符 // 检测中文字符和其他可能有问题的特殊字符
if (/[\u4e00-\u9fa5]/.test(path)) { if (/[\u4e00-\u9fa5]/.test(path)) {
return '路径包含中文字符,请迁移至全英文目录' return DB_PATH_CHINESE_ERROR
} }
return null return null
} }
const dbPathValidationError = validatePath(dbPath)
const handleDbPathChange = (value: string) => {
setDbPath(value)
const validationError = validatePath(value)
if (validationError) {
setError(validationError)
return
}
if (error === DB_PATH_CHINESE_ERROR) {
setError('')
}
}
const handleSelectPath = async () => { const handleSelectPath = async () => {
try { try {
@@ -236,10 +250,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0] const selectedPath = result.filePaths[0]
const validationError = validatePath(selectedPath) const validationError = validatePath(selectedPath)
setDbPath(selectedPath)
if (validationError) { if (validationError) {
setError(validationError) setError(validationError)
} else { } else {
setDbPath(selectedPath)
setError('') setError('')
} }
} }
@@ -256,10 +270,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const result = await window.electronAPI.dbPath.autoDetect() const result = await window.electronAPI.dbPath.autoDetect()
if (result.success && result.path) { if (result.success && result.path) {
const validationError = validatePath(result.path) const validationError = validatePath(result.path)
setDbPath(result.path)
if (validationError) { if (validationError) {
setError(validationError) setError(validationError)
} else { } else {
setDbPath(result.path)
setError('') setError('')
} }
} else { } else {
@@ -426,7 +440,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const canGoNext = () => { const canGoNext = () => {
if (currentStep.id === 'intro') return true if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath) if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
if (currentStep.id === 'cache') return true if (currentStep.id === 'cache') return true
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid) if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
if (currentStep.id === 'image') return true if (currentStep.id === 'image') return true
@@ -442,6 +456,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const handleNext = () => { const handleNext = () => {
if (!canGoNext()) { if (!canGoNext()) {
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录') if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
if (currentStep.id === 'key') { if (currentStep.id === 'key') {
if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符') if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符')
else if (!wxid) setError('未能自动识别 wxid请尝试重新获取或检查目录') else if (!wxid) setError('未能自动识别 wxid请尝试重新获取或检查目录')
@@ -664,7 +679,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
className="field-input" className="field-input"
placeholder={dbPathPlaceholder} placeholder={dbPathPlaceholder}
value={dbPath} value={dbPath}
onChange={(e) => setDbPath(e.target.value)} onChange={(e) => handleDbPathChange(e.target.value)}
/> />
</div> </div>
<div className="action-row"> <div className="action-row">

View File

@@ -343,6 +343,52 @@ export interface ElectronAPI {
}> }>
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }> getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }>
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
getResourceMessages: (options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => Promise<{
success: boolean
items?: Array<Message & {
sessionId: string
sessionDisplayName?: string
resourceType: 'image' | 'video' | 'voice' | 'file'
}>
total?: number
hasMore?: boolean
error?: string
}>
getMediaStream: (options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
@@ -357,13 +403,33 @@ export interface ElectronAPI {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean> resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
error?: string
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => void) => () => void
} }
video: { video: {
getVideoInfo: (videoMd5: string) => Promise<{ getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => Promise<{
success: boolean success: boolean
exists: boolean exists: boolean
videoUrl?: string videoUrl?: string
@@ -881,6 +947,28 @@ export interface ElectronAPI {
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }> deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }>
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }> downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }>
getCacheMigrationStatus: () => Promise<{
success: boolean
needed: boolean
inProgress?: boolean
totalFiles?: number
legacyBaseDir?: string
currentBaseDir?: string
items?: Array<{ label: string; sourceDir: string; targetDir: string; fileCount: number }>
error?: string
}>
startCacheMigration: () => Promise<{ success: boolean; copied?: number; skipped?: number; totalFiles?: number; message?: string; error?: string }>
onCacheMigrationProgress: (callback: (payload: {
status: 'running' | 'done' | 'error'
phase: 'copying' | 'cleanup' | 'done' | 'error'
current: number
total: number
copied: number
skipped: number
remaining: number
message?: string
currentItemLabel?: string
}) => void) => () => void
} }
cloud: { cloud: {
init: () => Promise<void> init: () => Promise<void>

View File

@@ -41,7 +41,7 @@ export default defineConfig({
'shelljs', 'shelljs',
'exceljs', 'exceljs',
'node-llama-cpp', 'node-llama-cpp',
'sudo-prompt' '@vscode/sudo-prompt'
] ]
} }
} }