mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-06 23:15:58 +00:00
Compare commits
5 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8aaae5616 | ||
|
|
45deb99e3d | ||
|
|
b821d370f9 | ||
|
|
60248b28f8 | ||
|
|
d128bedffa |
367
electron/main.ts
367
electron/main.ts
@@ -4,7 +4,7 @@ import { Worker } from 'worker_threads'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join, dirname } from 'path'
|
||||
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 { ConfigService } from './services/config'
|
||||
import { dbPathService } from './services/dbPathService'
|
||||
@@ -27,7 +27,7 @@ import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||
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 { messagePushService } from './services/messagePushService'
|
||||
import { bizService } from './services/bizService'
|
||||
@@ -740,6 +740,14 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
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 鉴权
|
||||
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 处理器
|
||||
function registerIpcHandlers() {
|
||||
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 {
|
||||
const result = await videoService.getVideoInfo(videoMd5)
|
||||
const result = await videoService.getVideoInfo(videoMd5, options)
|
||||
return { success: true, ...result }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e), exists: false }
|
||||
@@ -2088,6 +2315,28 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
|
||||
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) => {
|
||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||
})
|
||||
@@ -2222,17 +2471,121 @@ function registerIpcHandlers() {
|
||||
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 }) => {
|
||||
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)
|
||||
})
|
||||
ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => {
|
||||
imagePreloadService.enqueue(payloads || [])
|
||||
ipcMain.handle(
|
||||
'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
|
||||
})
|
||||
|
||||
|
||||
@@ -226,6 +226,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', 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),
|
||||
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) => {
|
||||
@@ -250,10 +266,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
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),
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||
ipcRenderer.invoke('image:preload', payloads),
|
||||
resolveCacheBatch: (
|
||||
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) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
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)
|
||||
ipcRenderer.on('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: {
|
||||
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)
|
||||
},
|
||||
|
||||
@@ -418,7 +461,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||
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: {
|
||||
|
||||
@@ -142,6 +142,14 @@ export interface Message {
|
||||
_db_path?: string // 内部字段:记录消息所属数据库路径
|
||||
}
|
||||
|
||||
type ResourceMessageType = 'image' | 'video' | 'voice' | 'file'
|
||||
|
||||
interface ResourceMessageItem extends Message {
|
||||
sessionId: string
|
||||
sessionDisplayName?: string
|
||||
resourceType: ResourceMessageType
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
username: 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 }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
|
||||
@@ -55,11 +55,14 @@ type DecryptResult = {
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
|
||||
type CachedImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
@@ -126,7 +129,9 @@ export class ImageDecryptService {
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
@@ -146,7 +151,9 @@ export class ImageDecryptService {
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
@@ -167,6 +174,7 @@ export class ImageDecryptService {
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
|
||||
|
||||
if (payload.force) {
|
||||
for (const key of cacheKeys) {
|
||||
@@ -176,6 +184,7 @@ export class ImageDecryptService {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -191,6 +200,7 @@ export class ImageDecryptService {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
}
|
||||
@@ -201,6 +211,7 @@ export class ImageDecryptService {
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -209,7 +220,10 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
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)
|
||||
this.pending.set(cacheKey, task)
|
||||
@@ -261,49 +275,93 @@ export class ImageDecryptService {
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
}
|
||||
|
||||
const datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: !payload.force,
|
||||
skipResolvedCache: Boolean(payload.force),
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
let datPath: string | null = null
|
||||
let usedHdAttempt = false
|
||||
let fallbackToThumbnail = false
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
// force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。
|
||||
if (payload.force) {
|
||||
usedHdAttempt = true
|
||||
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) {
|
||||
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: '未找到图片文件' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
|
||||
@@ -319,6 +377,7 @@ export class ImageDecryptService {
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
@@ -340,6 +399,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
|
||||
@@ -347,7 +407,9 @@ export class ImageDecryptService {
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
|
||||
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
@@ -363,10 +425,12 @@ export class ImageDecryptService {
|
||||
const finalExt = ext || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
if (finalExt === '.hevc') {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||
@@ -378,15 +442,19 @@ export class ImageDecryptService {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
} else {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
}
|
||||
const localPath = payload.preferFilePath
|
||||
? outputPath
|
||||
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
|
||||
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, emitPath)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, '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> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
|
||||
@@ -6,36 +6,57 @@ type PreloadImagePayload = {
|
||||
imageDatName?: string
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
private queue: PreloadTask[] = []
|
||||
private pending = new Set<string>()
|
||||
private active = 0
|
||||
private readonly maxConcurrent = 2
|
||||
private activeCache = 0
|
||||
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
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
if (!cacheKey) continue
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key })
|
||||
this.queue.push({ ...payload, key, allowDecrypt })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
while (this.active < this.maxConcurrent && this.queue.length > 0) {
|
||||
const task = this.queue.shift()
|
||||
while (this.queue.length > 0) {
|
||||
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
|
||||
this.active += 1
|
||||
|
||||
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||
else this.activeCache += 1
|
||||
|
||||
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.processQueue()
|
||||
})
|
||||
@@ -49,9 +70,11 @@ export class ImagePreloadService {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
disableUpdateCheck: !task.allowDecrypt
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
|
||||
@@ -17,9 +17,9 @@ export class KeyServiceLinux {
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this.sudo = require('sudo-prompt');
|
||||
this.sudo = require('@vscode/sudo-prompt');
|
||||
} 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
344
electron/services/linuxNotificationService.ts
Normal file
344
electron/services/linuxNotificationService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
@@ -801,14 +802,25 @@ class SnsService {
|
||||
}
|
||||
|
||||
private getSnsCacheDir(): string {
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const snsCacheDir = join(baseDir, 'sns_cache')
|
||||
if (!existsSync(snsCacheDir)) {
|
||||
mkdirSync(snsCacheDir, { recursive: true })
|
||||
}
|
||||
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 {
|
||||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||
@@ -1832,7 +1844,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const isVideo = isVideoUrl(url)
|
||||
const cachePath = this.getCacheFilePath(url)
|
||||
|
||||
// 1. 尝试从磁盘缓存读取
|
||||
// 1. 优先尝试从当前缓存目录读取
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||
@@ -2293,9 +2305,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
|
||||
const fs = require('fs')
|
||||
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
||||
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
||||
const emojiDir = this.getEmojiCacheDir()
|
||||
|
||||
// 检查本地缓存
|
||||
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
@@ -22,15 +25,50 @@ interface VideoIndexEntry {
|
||||
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 {
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||
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 videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
|
||||
private readonly maxPosterExtractConcurrency = 1
|
||||
private readonly maxCacheEntries = 2000
|
||||
private readonly maxIndexEntries = 6
|
||||
|
||||
@@ -256,12 +294,10 @@ class VideoService {
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||
try {
|
||||
if (!filePath || !existsSync(filePath)) return undefined
|
||||
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
@@ -355,7 +391,12 @@ class VideoService {
|
||||
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()
|
||||
if (!normalizedMd5) return null
|
||||
|
||||
@@ -379,8 +420,8 @@ class VideoService {
|
||||
}
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
|
||||
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
@@ -388,7 +429,12 @@ class VideoService {
|
||||
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 {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
@@ -416,8 +462,8 @@ class VideoService {
|
||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||
return {
|
||||
videoUrl: videoPath,
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
|
||||
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
@@ -427,14 +473,165 @@ class VideoService {
|
||||
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获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {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 includePoster = options?.includePoster !== false
|
||||
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
@@ -446,7 +643,7 @@ class VideoService {
|
||||
}
|
||||
|
||||
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)
|
||||
if (cachedInfo) return cachedInfo
|
||||
@@ -465,16 +662,18 @@ class VideoService {
|
||||
}
|
||||
|
||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
|
||||
if (indexed) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return indexed
|
||||
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
|
||||
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) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return fallback
|
||||
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return withPoster
|
||||
}
|
||||
|
||||
const miss = { exists: false }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import * as fzstd from 'fzstd'
|
||||
|
||||
//数据服务初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
@@ -80,6 +81,7 @@ export class WcdbCore {
|
||||
private wcdbGetSessionMessageDateCounts: any = null
|
||||
private wcdbGetSessionMessageDateCountsBatch: any = null
|
||||
private wcdbGetMessagesByType: any = null
|
||||
private wcdbScanMediaStream: any = null
|
||||
private wcdbGetHeadImageBuffers: any = null
|
||||
private wcdbSearchMessages: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
@@ -1013,6 +1015,11 @@ export class WcdbCore {
|
||||
} catch {
|
||||
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 {
|
||||
this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
} 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 }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
|
||||
@@ -268,6 +268,37 @@ export class WcdbService {
|
||||
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
18
electron/types/dbus.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -80,6 +80,9 @@ if (parentPort) {
|
||||
case 'getMessagesByType':
|
||||
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getMediaStream':
|
||||
result = await core.getMediaStream(payload.options)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
|
||||
@@ -1,224 +1,333 @@
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from '../services/config'
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "../services/config";
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
// Linux D-Bus通知服务
|
||||
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() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
lastNotificationData = null;
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
const win = notificationWindow;
|
||||
notificationWindow = null;
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
try {
|
||||
win.destroy();
|
||||
} catch (error) {
|
||||
console.warn("[NotificationWindow] Failed to destroy window:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||
const iconPath = isDev
|
||||
? join(__dirname, "../../public/icon.ico")
|
||||
: join(process.resourcesPath, "icon.ico");
|
||||
|
||||
console.log('[NotificationWindow] Creating window...')
|
||||
const width = 344
|
||||
const height = 114
|
||||
console.log("[NotificationWindow] Creating window...");
|
||||
const width = 344;
|
||||
const height = 114;
|
||||
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
}
|
||||
})
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
},
|
||||
});
|
||||
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
|
||||
|
||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||
notificationWindow.loadURL(loadUrl)
|
||||
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||
notificationWindow.loadURL(loadUrl);
|
||||
|
||||
notificationWindow.on('closed', () => {
|
||||
notificationWindow = null
|
||||
})
|
||||
notificationWindow.on("closed", () => {
|
||||
notificationWindow = null;
|
||||
});
|
||||
|
||||
return notificationWindow
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
export async function showNotification(data: any) {
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance()
|
||||
const enabled = await config.get('notificationEnabled')
|
||||
if (enabled === false) return // 默认为 true
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance();
|
||||
const enabled = await config.get("notificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||
const filterList = config.get('notificationFilterList') || []
|
||||
const sessionId = data.sessionId
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get("notificationFilterMode") || "all";
|
||||
const filterList = config.get("notificationFilterList") || [];
|
||||
const sessionId = data.sessionId;
|
||||
|
||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId)
|
||||
if (filterMode === 'whitelist' && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (filterMode === 'blacklist' && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (sessionId && filterMode !== "all" && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId);
|
||||
if (filterMode === "whitelist" && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return;
|
||||
}
|
||||
|
||||
let win = notificationWindow
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow()
|
||||
if (filterMode === "blacklist" && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!win) return
|
||||
// Linux 使用 D-Bus 通知
|
||||
if (isLinux) {
|
||||
await showLinuxNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once('ready-to-show', () => {
|
||||
showAndSend(win!, data)
|
||||
})
|
||||
} else {
|
||||
showAndSend(win, data)
|
||||
}
|
||||
let win = notificationWindow;
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow();
|
||||
}
|
||||
|
||||
if (!win) return;
|
||||
|
||||
// 确保加载完成
|
||||
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) {
|
||||
lastNotificationData = data
|
||||
const config = ConfigService.getInstance()
|
||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||
lastNotificationData = data;
|
||||
const config = ConfigService.getInstance();
|
||||
const position = (await config.get("notificationPosition")) || "top-right";
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = position === 'top-center' ? 280 : 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } =
|
||||
screen.getPrimaryDisplay().workAreaSize;
|
||||
const winWidth = position === "top-center" ? 280 : 344;
|
||||
const winHeight = 114;
|
||||
const padding = 20;
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top-center':
|
||||
x = (screenWidth - winWidth) / 2
|
||||
y = padding
|
||||
break
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
case 'top-left':
|
||||
x = padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-left':
|
||||
x = padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
switch (position) {
|
||||
case "top-center":
|
||||
x = (screenWidth - winWidth) / 2;
|
||||
y = padding;
|
||||
break;
|
||||
case "top-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
case "top-left":
|
||||
x = padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-left":
|
||||
x = padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
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))
|
||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||
ipcMain.handle("notification:show", (_, data) => {
|
||||
showNotification(data);
|
||||
});
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false)
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
ipcMain.handle("notification:close", () => {
|
||||
if (isLinux && linuxNotificationService) {
|
||||
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
export function registerNotificationHandlers() {
|
||||
ipcMain.handle('notification:show', (_, data) => {
|
||||
showNotification(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('notification:close', () => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide()
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on('notification:ready', (event) => {
|
||||
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
|
||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// 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 中处理 (导航)
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||
if (isLinux) {
|
||||
// Linux 通知通过D-Bus自动调整大小
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// 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 中处理 (导航)
|
||||
}
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "4.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -29,7 +30,6 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -43,7 +43,7 @@
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
}
|
||||
@@ -3050,6 +3050,12 @@
|
||||
"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": {
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
@@ -9456,13 +9462,6 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
@@ -10066,9 +10065,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"dbus-native": "^0.4.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -43,7 +45,6 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -57,7 +58,7 @@
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
|
||||
Binary file not shown.
54
src/App.tsx
54
src/App.tsx
@@ -22,6 +22,7 @@ import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import BizPage from './pages/BizPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
|
||||
@@ -669,32 +670,32 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWaylandWarning && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>环境兼容性提示 (Wayland)</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||
<br />
|
||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*{showWaylandWarning && (*/}
|
||||
{/* <div className="agreement-overlay">*/}
|
||||
{/* <div className="agreement-modal">*/}
|
||||
{/* <div className="agreement-header">*/}
|
||||
{/* <Shield size={32} />*/}
|
||||
{/* <h2>环境兼容性提示 (Wayland)</h2>*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="agreement-content">*/}
|
||||
{/* <div className="agreement-text">*/}
|
||||
{/* <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>*/}
|
||||
{/* <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>*/}
|
||||
{/* <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>*/}
|
||||
{/* <br />*/}
|
||||
{/* <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>*/}
|
||||
{/* <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>*/}
|
||||
{/* <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="agreement-footer">*/}
|
||||
{/* <div className="agreement-actions">*/}
|
||||
{/* <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
@@ -743,6 +744,7 @@ function App() {
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
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 { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
@@ -429,6 +429,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">通讯录</span>
|
||||
</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
|
||||
to="/analytics"
|
||||
|
||||
620
src/pages/ResourcesPage.scss
Normal file
620
src/pages/ResourcesPage.scss
Normal 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
1265
src/pages/ResourcesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
background: var(--sns-card-bg);
|
||||
border-radius: var(--sns-border-radius-lg);
|
||||
|
||||
@@ -66,6 +66,33 @@ type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
||||
|
||||
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 => {
|
||||
try {
|
||||
@@ -162,6 +189,12 @@ export default function SnsPage() {
|
||||
const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null)
|
||||
const [triggerLoading, setTriggerLoading] = useState(false)
|
||||
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 jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -185,6 +218,7 @@ export default function SnsPage() {
|
||||
const contactsCountBatchTimerRef = useRef<number | null>(null)
|
||||
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
|
||||
const jumpDateRequestSeqRef = useRef(0)
|
||||
const checkedCacheMigrationRef = useRef(false)
|
||||
|
||||
// Sync posts ref
|
||||
useEffect(() => {
|
||||
@@ -595,6 +629,133 @@ export default function SnsPage() {
|
||||
}
|
||||
}, [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 = () => {
|
||||
if (overviewStatsStatus === 'error') {
|
||||
return (
|
||||
@@ -1256,7 +1417,8 @@ export default function SnsPage() {
|
||||
void hydrateSnsPageCache()
|
||||
loadContacts()
|
||||
loadOverviewStats()
|
||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||
void checkCacheMigrationStatus()
|
||||
}, [checkCacheMigrationStatus, hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||
|
||||
useEffect(() => {
|
||||
const syncCurrentUserProfile = async () => {
|
||||
@@ -1659,6 +1821,117 @@ export default function SnsPage() {
|
||||
</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 && (
|
||||
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||
const isWindows = !isMac && !isLinux
|
||||
|
||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
|
||||
const dbPathPlaceholder = isMac
|
||||
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||
: isLinux
|
||||
@@ -221,10 +222,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (!path) return null
|
||||
// 检测中文字符和其他可能有问题的特殊字符
|
||||
if (/[\u4e00-\u9fa5]/.test(path)) {
|
||||
return '路径包含中文字符,请迁移至全英文目录'
|
||||
return DB_PATH_CHINESE_ERROR
|
||||
}
|
||||
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 () => {
|
||||
try {
|
||||
@@ -236,10 +250,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0]
|
||||
const validationError = validatePath(selectedPath)
|
||||
setDbPath(selectedPath)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
} else {
|
||||
setDbPath(selectedPath)
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
@@ -256,10 +270,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const result = await window.electronAPI.dbPath.autoDetect()
|
||||
if (result.success && result.path) {
|
||||
const validationError = validatePath(result.path)
|
||||
setDbPath(result.path)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
} else {
|
||||
setDbPath(result.path)
|
||||
setError('')
|
||||
}
|
||||
} else {
|
||||
@@ -426,7 +440,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
const canGoNext = () => {
|
||||
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 === 'key') return decryptKey.length === 64 && Boolean(wxid)
|
||||
if (currentStep.id === 'image') return true
|
||||
@@ -442,6 +456,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const handleNext = () => {
|
||||
if (!canGoNext()) {
|
||||
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
|
||||
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
|
||||
if (currentStep.id === 'key') {
|
||||
if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符')
|
||||
else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录')
|
||||
@@ -664,7 +679,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
className="field-input"
|
||||
placeholder={dbPathPlaceholder}
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
onChange={(e) => handleDbPathChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
|
||||
94
src/types/electron.d.ts
vendored
94
src/types/electron.d.ts
vendored
@@ -343,6 +343,52 @@ export interface ElectronAPI {
|
||||
}>
|
||||
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; 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 }>
|
||||
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
|
||||
@@ -357,13 +403,33 @@ export interface ElectronAPI {
|
||||
|
||||
image: {
|
||||
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 }>
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
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
|
||||
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: {
|
||||
getVideoInfo: (videoMd5: string) => Promise<{
|
||||
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => Promise<{
|
||||
success: boolean
|
||||
exists: boolean
|
||||
videoUrl?: string
|
||||
@@ -881,6 +947,28 @@ export interface ElectronAPI {
|
||||
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: 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 }>
|
||||
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: {
|
||||
init: () => Promise<void>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineConfig({
|
||||
'shelljs',
|
||||
'exceljs',
|
||||
'node-llama-cpp',
|
||||
'sudo-prompt'
|
||||
'@vscode/sudo-prompt'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user