mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-10 15:08:31 +00:00
chore: merge upstream main into fork main
This commit is contained in:
219
electron/services/avatarFileCacheService.ts
Normal file
219
electron/services/avatarFileCacheService.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "./config";
|
||||
|
||||
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
|
||||
export class AvatarFileCacheService {
|
||||
private static instance: AvatarFileCacheService | null = null;
|
||||
|
||||
// 头像文件缓存目录
|
||||
private readonly cacheDir: string;
|
||||
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
|
||||
private readonly pendingDownloads: Map<string, Promise<string | null>> =
|
||||
new Map();
|
||||
// LRU 追踪:文件路径->最后访问时间
|
||||
private readonly lruOrder: string[] = [];
|
||||
private readonly maxCacheFiles = 100;
|
||||
|
||||
private constructor() {
|
||||
const basePath = ConfigService.getInstance().getCacheBasePath();
|
||||
this.cacheDir = join(basePath, "avatar-files");
|
||||
this.ensureCacheDir();
|
||||
this.loadLruOrder();
|
||||
}
|
||||
|
||||
public static getInstance(): AvatarFileCacheService {
|
||||
if (!AvatarFileCacheService.instance) {
|
||||
AvatarFileCacheService.instance = new AvatarFileCacheService();
|
||||
}
|
||||
return AvatarFileCacheService.instance;
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
// 同步确保目录存在(构造函数调用)
|
||||
try {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async ensureCacheDirAsync(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getFilePath(url: string): string {
|
||||
// 使用URL的hash作为文件名,避免特殊字符问题
|
||||
const hash = this.hashString(url);
|
||||
return join(this.cacheDir, `avatar_${hash}.png`);
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // 转换为32位整数
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
private async loadLruOrder(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
// 按修改时间排序(旧的在前)
|
||||
const filesWithTime: { file: string; mtime: number }[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(this.cacheDir, entry));
|
||||
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
|
||||
} catch {}
|
||||
}
|
||||
filesWithTime.sort((a, b) => a.mtime - b.mtime);
|
||||
this.lruOrder.length = 0;
|
||||
this.lruOrder.push(...filesWithTime.map((f) => f.file));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private updateLru(fileName: string): void {
|
||||
const index = this.lruOrder.indexOf(fileName);
|
||||
if (index > -1) {
|
||||
this.lruOrder.splice(index, 1);
|
||||
}
|
||||
this.lruOrder.push(fileName);
|
||||
}
|
||||
|
||||
private async evictIfNeeded(): Promise<void> {
|
||||
while (this.lruOrder.length >= this.maxCacheFiles) {
|
||||
const oldest = this.lruOrder.shift();
|
||||
if (oldest) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, oldest));
|
||||
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAvatar(url: string): Promise<string | null> {
|
||||
const localPath = this.getFilePath(url);
|
||||
|
||||
// 检查文件是否已存在
|
||||
try {
|
||||
await fs.access(localPath);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
return localPath;
|
||||
} catch {}
|
||||
|
||||
await this.ensureCacheDirAsync();
|
||||
await this.evictIfNeeded();
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
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) {
|
||||
resolve(null);
|
||||
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);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
console.log(
|
||||
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
|
||||
);
|
||||
resolve(localPath);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
};
|
||||
|
||||
const req = url.startsWith("https")
|
||||
? https.get(url, options, callback)
|
||||
: http.get(url, options, callback);
|
||||
|
||||
req.on("error", () => resolve(null));
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取头像本地文件路径,如果需要会下载
|
||||
* 同一URL并发调用会复用同一个下载任务
|
||||
*/
|
||||
async getAvatarPath(url: string): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
|
||||
// 检查是否有正在进行的下载
|
||||
const pending = this.pendingDownloads.get(url);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// 发起新下载
|
||||
const downloadPromise = this.downloadAvatar(url);
|
||||
this.pendingDownloads.set(url, downloadPromise);
|
||||
|
||||
try {
|
||||
const result = await downloadPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingDownloads.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有缓存文件(App退出时调用)
|
||||
async clearCache(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, entry));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.lruOrder.length = 0;
|
||||
console.log("[AvatarFileCache] Cache cleared");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 获取当前缓存的文件数量
|
||||
async getCacheCount(): Promise<number> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
return entries.filter(
|
||||
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
|
||||
).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarFileCache = AvatarFileCacheService.getInstance();
|
||||
@@ -323,6 +323,8 @@ class ChatService {
|
||||
private contactLabelNameMapCacheAt = 0
|
||||
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
|
||||
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
|
||||
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
|
||||
private readonly slowGetContactsLogThresholdMs = 1200
|
||||
|
||||
@@ -513,6 +515,43 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
const [messageSnapshot, mediaResult] = await Promise.all([
|
||||
this.getMessageDbCountSnapshot(true),
|
||||
wcdbService.listMediaDbs()
|
||||
])
|
||||
|
||||
let messageDbCount = 0
|
||||
if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) {
|
||||
messageDbCount = messageSnapshot.dbPaths.length
|
||||
}
|
||||
|
||||
let mediaDbCount = 0
|
||||
if (mediaResult.success && Array.isArray(mediaResult.data)) {
|
||||
this.mediaDbsCache = [...mediaResult.data]
|
||||
this.mediaDbsCacheTime = Date.now()
|
||||
mediaDbCount = mediaResult.data.length
|
||||
}
|
||||
|
||||
if (!messageSnapshot.success && !mediaResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, messageDbCount, mediaDbCount }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && wcdbService.isReady()) {
|
||||
return { success: true }
|
||||
@@ -1362,8 +1401,50 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private getContactsCacheScope(): string {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
return `${dbPath}::${myWxid}`
|
||||
}
|
||||
|
||||
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
|
||||
return (contacts || []).map((contact) => ({
|
||||
...contact,
|
||||
labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels
|
||||
}))
|
||||
}
|
||||
|
||||
private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null {
|
||||
const cached = this.contactsMemoryCache.get(mode)
|
||||
if (!cached) return null
|
||||
if (cached.scope !== scope) return null
|
||||
if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null
|
||||
return this.cloneContacts(cached.contacts)
|
||||
}
|
||||
|
||||
private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void {
|
||||
this.contactsMemoryCache.set(mode, {
|
||||
scope,
|
||||
updatedAt: Date.now(),
|
||||
contacts: this.cloneContacts(contacts)
|
||||
})
|
||||
}
|
||||
|
||||
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||
const isLiteMode = options?.lite === true
|
||||
const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full'
|
||||
const cacheScope = this.getContactsCacheScope()
|
||||
const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope)
|
||||
if (cachedContacts) {
|
||||
return { success: true, contacts: cachedContacts }
|
||||
}
|
||||
if (isLiteMode) {
|
||||
const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope)
|
||||
if (fullCachedContacts) {
|
||||
return { success: true, contacts: fullCachedContacts }
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
const stageDurations: Array<{ stage: string; ms: number }> = []
|
||||
const captureStage = (stage: string, stageStartedAt: number) => {
|
||||
@@ -1487,6 +1568,10 @@ class ChatService {
|
||||
.join(', ')
|
||||
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
|
||||
}
|
||||
this.setContactsMemoryCache(mode, cacheScope, result)
|
||||
if (!isLiteMode) {
|
||||
this.setContactsMemoryCache('lite', cacheScope, result)
|
||||
}
|
||||
return { success: true, contacts: result }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取通讯录失败:', e)
|
||||
@@ -2886,6 +2971,7 @@ class ChatService {
|
||||
this.sessionTablesCache.clear()
|
||||
this.messageTableColumnsCache.clear()
|
||||
this.messageDbCountSnapshotCache = null
|
||||
this.contactsMemoryCache.clear()
|
||||
this.refreshSessionStatsCacheScope(scope)
|
||||
this.refreshGroupMyMessageCountCacheScope(scope)
|
||||
}
|
||||
@@ -5983,6 +6069,7 @@ class ChatService {
|
||||
if (includeContacts) {
|
||||
this.avatarCache.clear()
|
||||
this.contactCacheService.clear()
|
||||
this.contactsMemoryCache.clear()
|
||||
}
|
||||
|
||||
if (includeMessages) {
|
||||
|
||||
@@ -270,7 +270,9 @@ export class ConfigService {
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
const boolValue = value === true || value === 'true'
|
||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
@@ -649,7 +651,7 @@ export class ConfigService {
|
||||
|
||||
clearHelloSecret(): void {
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
// === 迁移 ===
|
||||
@@ -658,13 +660,18 @@ export class ConfigService {
|
||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'boolean') {
|
||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||
if (rawEnabled === true || rawEnabled === 'true') {
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||
this.store.set('authEnabled', false as any)
|
||||
}
|
||||
|
||||
const rawUseHello: any = this.store.get('authUseHello')
|
||||
if (typeof rawUseHello === 'boolean') {
|
||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||
if (rawUseHello === true || rawUseHello === 'true') {
|
||||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
const rawPassword: any = this.store.get('authPassword')
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface ExportOptions {
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
@@ -494,6 +495,80 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeExportFileNamePart(value: string): string {
|
||||
return String(value || '')
|
||||
.replace(/[<>:"\/\\|?*]/g, '_')
|
||||
.replace(/\.+$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
||||
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||
}
|
||||
|
||||
private formatDateTokenBySeconds(seconds?: number): string | null {
|
||||
if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null
|
||||
const date = new Date(Math.floor(Number(seconds)) * 1000)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}${m}${d}`
|
||||
}
|
||||
|
||||
private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
|
||||
const start = this.formatDateTokenBySeconds(dateRange?.start)
|
||||
const end = this.formatDateTokenBySeconds(dateRange?.end)
|
||||
if (start && end) {
|
||||
if (start === end) return start
|
||||
return start < end ? `${start}-${end}` : `${end}-${start}`
|
||||
}
|
||||
if (start) return `${start}-至今`
|
||||
if (end) return `截至-${end}`
|
||||
return '全部时间'
|
||||
}
|
||||
|
||||
private buildSessionExportBaseName(
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
options: ExportOptions
|
||||
): string {
|
||||
const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session'
|
||||
const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '')
|
||||
const namingMode = this.normalizeFileNamingMode(options.fileNamingMode)
|
||||
const parts = [baseName]
|
||||
if (suffix) parts.push(suffix)
|
||||
if (namingMode === 'date-range') {
|
||||
parts.push(this.buildDateRangeFileNamePart(options.dateRange))
|
||||
}
|
||||
return this.sanitizeExportFileNamePart(parts.join('_')) || 'session'
|
||||
}
|
||||
|
||||
private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
|
||||
const dir = path.dirname(preferredPath)
|
||||
const ext = path.extname(preferredPath)
|
||||
const base = path.basename(preferredPath, ext)
|
||||
|
||||
for (let attempt = 0; attempt < 10000; attempt += 1) {
|
||||
const candidate = attempt === 0
|
||||
? preferredPath
|
||||
: path.join(dir, `${base}_${attempt + 1}${ext}`)
|
||||
|
||||
if (reservedPaths.has(candidate)) continue
|
||||
|
||||
const exists = await this.pathExists(candidate)
|
||||
if (reservedPaths.has(candidate)) continue
|
||||
if (exists) continue
|
||||
|
||||
reservedPaths.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
|
||||
reservedPaths.add(fallback)
|
||||
return fallback
|
||||
}
|
||||
|
||||
private isCloneUnsupportedError(code: string | undefined): boolean {
|
||||
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
|
||||
}
|
||||
@@ -8911,6 +8986,7 @@ class ExportService {
|
||||
? path.join(outputDir, 'texts')
|
||||
: outputDir
|
||||
const createdTaskDirs = new Set<string>()
|
||||
const reservedOutputPaths = new Set<string>()
|
||||
const ensureTaskDir = async (dirPath: string) => {
|
||||
if (createdTaskDirs.has(dirPath)) return
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
@@ -9159,10 +9235,8 @@ class ExportService {
|
||||
phaseLabel: '准备导出'
|
||||
})
|
||||
|
||||
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
|
||||
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||
const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
|
||||
const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
|
||||
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
|
||||
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
|
||||
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
|
||||
@@ -9180,13 +9254,13 @@ class ExportService {
|
||||
else if (effectiveOptions.format === 'txt') ext = '.txt'
|
||||
else if (effectiveOptions.format === 'weclone') ext = '.csv'
|
||||
else if (effectiveOptions.format === 'html') ext = '.html'
|
||||
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
|
||||
typeof messageCountHint === 'number' &&
|
||||
messageCountHint >= 0 &&
|
||||
typeof latestTimestampHint === 'number' &&
|
||||
latestTimestampHint > 0 &&
|
||||
await this.pathExists(outputPath)
|
||||
await this.pathExists(preferredOutputPath)
|
||||
if (canTrySkipUnchanged) {
|
||||
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
|
||||
const hasNoDataChange = Boolean(
|
||||
@@ -9213,6 +9287,10 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = fileNamingMode === 'date-range'
|
||||
? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
|
||||
: preferredOutputPath
|
||||
|
||||
let result: { success: boolean; error?: string }
|
||||
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||
|
||||
@@ -63,6 +63,7 @@ type CachedImagePayload = {
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
@@ -116,7 +117,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -673,41 +676,53 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!imageDatName) return null
|
||||
if (!skipResolvedCache) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
|
||||
this.resolvedCache.set(imageDatName, datPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
return datPath
|
||||
}
|
||||
const normalized = this.normalizeDatBase(imageDatName)
|
||||
if (normalized !== imageDatName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
|
||||
this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
return normalizedPath
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1042,7 +1057,7 @@ export class ImageDecryptService {
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -1058,8 +1073,10 @@ export class ImageDecryptService {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
@@ -1070,9 +1087,13 @@ export class ImageDecryptService {
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
|
||||
@@ -8,11 +8,13 @@ type PreloadImagePayload = {
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
@@ -27,6 +29,7 @@ export class ImagePreloadService {
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
@@ -34,7 +37,7 @@ export class ImagePreloadService {
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key, allowDecrypt })
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
@@ -71,7 +74,8 @@ export class ImagePreloadService {
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
disableUpdateCheck: !task.allowDecrypt
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { app, Notification } from 'electron'
|
||||
import { Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
|
||||
@@ -38,6 +36,13 @@ const API_TIMEOUT_MS = 45_000
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiInsightEnabled',
|
||||
'aiInsightScanIntervalHours',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,33 +51,17 @@ interface TodayTriggerRecord {
|
||||
timestamps: number[]
|
||||
}
|
||||
|
||||
// ─── 桌面日志 ─────────────────────────────────────────────────────────────────
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。
|
||||
* 文件名带当天日期,每天自动换一个新文件,旧文件保留。
|
||||
* 仅输出到 console,不落盘到文件。
|
||||
*/
|
||||
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
const line = `[${dateStr} ${timeStr}] [${level}] ${message}\n`
|
||||
|
||||
// 同步到 console
|
||||
if (level === 'ERROR' || level === 'WARN') {
|
||||
console.warn(`[InsightService] ${message}`)
|
||||
} else {
|
||||
console.log(`[InsightService] ${message}`)
|
||||
}
|
||||
|
||||
// 异步写入桌面日志文件,避免同步磁盘 I/O 阻塞 Electron 主线程事件循环
|
||||
try {
|
||||
const desktopPath = app.getPath('desktop')
|
||||
const logFile = path.join(desktopPath, `weflow-insight-${dateStr}.log`)
|
||||
fs.appendFile(logFile, line, 'utf-8', () => { /* 失败静默处理 */ })
|
||||
} catch {
|
||||
// getPath 失败时静默处理
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
@@ -234,15 +223,64 @@ class InsightService {
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
insightLog('INFO', '已启动')
|
||||
this.scheduleSilenceScan()
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const hadActiveFlow =
|
||||
this.dbDebounceTimer !== null ||
|
||||
this.silenceScanTimer !== null ||
|
||||
this.silenceInitialDelayTimer !== null ||
|
||||
this.processing
|
||||
this.started = false
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
if (hadActiveFlow) {
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||
|
||||
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
|
||||
await this.refreshConfiguration(`config:${normalizedKey}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
}
|
||||
|
||||
private async refreshConfiguration(_reason: string): Promise<void> {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
return
|
||||
}
|
||||
this.scheduleSilenceScan()
|
||||
}
|
||||
|
||||
private clearRuntimeCache(): void {
|
||||
this.dbConnected = false
|
||||
this.sessionCache = null
|
||||
this.sessionCacheAt = 0
|
||||
this.lastActivityAnalysis.clear()
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
this.dbDebounceTimer = null
|
||||
@@ -255,7 +293,6 @@ class InsightService {
|
||||
clearTimeout(this.silenceInitialDelayTimer)
|
||||
this.silenceInitialDelayTimer = null
|
||||
}
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,9 +489,12 @@ class InsightService {
|
||||
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSilenceScan(): void {
|
||||
this.clearTimers()
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
|
||||
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||
const scheduleNext = () => {
|
||||
if (!this.started) return
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||
@@ -474,7 +514,6 @@ class InsightService {
|
||||
|
||||
private async runSilenceScan(): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
insightLog('INFO', '沉默扫描:AI 见解未启用,跳过')
|
||||
return
|
||||
}
|
||||
if (this.processing) {
|
||||
@@ -502,6 +541,7 @@ class InsightService {
|
||||
|
||||
let silentCount = 0
|
||||
for (const session of sessions) {
|
||||
if (!this.isEnabled()) return
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||
@@ -654,6 +694,7 @@ class InsightService {
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||
@@ -747,6 +788,7 @@ class InsightService {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
return
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
|
||||
@@ -61,6 +61,7 @@ export class KeyService {
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
@@ -68,11 +69,20 @@ export class KeyService {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
|
||||
@@ -25,13 +25,23 @@ export class KeyServiceLinux {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { join, basename, dirname } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_HELPER_PATH) {
|
||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||
}
|
||||
|
||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
||||
|
||||
private getImageScanHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||
}
|
||||
|
||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getDylibPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||
}
|
||||
|
||||
@@ -373,19 +403,71 @@ export class KeyServiceMac {
|
||||
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
|
||||
const baseDir = dirname(primaryBinaryPath)
|
||||
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
|
||||
const unique: string[] = []
|
||||
for (const name of names) {
|
||||
const full = join(baseDir, name)
|
||||
if (!existsSync(full)) continue
|
||||
if (!unique.includes(full)) unique.push(full)
|
||||
}
|
||||
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
|
||||
unique.unshift(primaryBinaryPath)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private ensureExecutableBitsBestEffort(paths: string[]): void {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const mode = statSync(p).mode
|
||||
if ((mode & 0o111) !== 0) continue
|
||||
chmodSync(p, mode | 0o111)
|
||||
} catch {
|
||||
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
|
||||
const existing = paths.filter(p => existsSync(p))
|
||||
if (existing.length === 0) return
|
||||
|
||||
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
|
||||
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
|
||||
const scriptLines = [
|
||||
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'with timeout of timeoutSec seconds',
|
||||
'do shell script chmodCmd with administrator privileges',
|
||||
'end timeout'
|
||||
]
|
||||
|
||||
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
timeout: timeoutMs + 10_000
|
||||
})
|
||||
}
|
||||
|
||||
private async getDbKeyByHelperElevated(
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<string> {
|
||||
const helperPath = this.getHelperPath()
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
const waitMs = Math.max(timeoutMs, 30_000)
|
||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||
const pid = await this.getWeChatPid()
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
|
||||
: ''
|
||||
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
|
||||
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
|
||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set cmd to ${JSON.stringify(privilegedCmd)}`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
@@ -721,10 +803,12 @@ export class KeyServiceMac {
|
||||
try {
|
||||
const helperPath = this.getImageScanHelperPath()
|
||||
const ciphertextHex = ciphertext.toString('hex')
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
|
||||
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||
if (!this._needsElevation) {
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
|
||||
if (direct.key) return direct.key
|
||||
if (direct.permissionError) {
|
||||
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||
@@ -735,7 +819,12 @@ export class KeyServiceMac {
|
||||
|
||||
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||
if (this._needsElevation) {
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
||||
try {
|
||||
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
|
||||
} catch (e: any) {
|
||||
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
|
||||
}
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
|
||||
if (elevated.key) return elevated.key
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -838,12 +927,19 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
private _spawnScanHelper(
|
||||
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
||||
helperPath: string,
|
||||
pid: number,
|
||||
ciphertextHex: string,
|
||||
elevated: boolean,
|
||||
artifactPaths: string[] = []
|
||||
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
if (elevated) {
|
||||
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
|
||||
: ''
|
||||
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
|
||||
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
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";
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
@@ -18,173 +11,96 @@ export interface LinuxNotificationData {
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
|
||||
let sessionBus: dbus.DBusConnection | null = null;
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let pendingNotifications: Map<number, LinuxNotificationData> = new Map();
|
||||
let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = new Map();
|
||||
const closeTimers: Map<number, NodeJS.Timeout> = 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;
|
||||
function nextNotificationId(): number {
|
||||
const id = notificationCounter;
|
||||
notificationCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
// 确保缓存目录存在
|
||||
async function ensureCacheDir(): Promise<string> {
|
||||
if (!avatarCacheDir) {
|
||||
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
|
||||
function clearNotificationState(notificationId: number): void {
|
||||
activeNotifications.delete(notificationId);
|
||||
const timer = closeTimers.get(notificationId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
closeTimers.delete(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
await fs.mkdir(avatarCacheDir, { recursive: true });
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[LinuxNotification] Failed to create avatar cache dir:",
|
||||
error,
|
||||
);
|
||||
console.error("[LinuxNotification] Callback error:", 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> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
|
||||
const appName = "WeFlow";
|
||||
const replaceId = 0;
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
|
||||
// 处理头像:下载到本地或使用URL
|
||||
let appIcon = "";
|
||||
let hints: any[] = [];
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
// 优先尝试下载到本地
|
||||
const localPath = await downloadAvatarToLocal(data.avatarUrl);
|
||||
if (localPath) {
|
||||
hints = [["image-path", ["s", localPath]]];
|
||||
}
|
||||
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.content,
|
||||
icon: iconPath,
|
||||
});
|
||||
|
||||
const notificationId = nextNotificationId();
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
currentNotification.close();
|
||||
}
|
||||
}, expireTimeout);
|
||||
closeTimers.set(notificationId, timer);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
@@ -194,59 +110,22 @@ export async function showLinuxNotification(
|
||||
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);
|
||||
}
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
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);
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
@@ -262,83 +141,34 @@ export function removeNotificationCallback(
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
}
|
||||
|
||||
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
// 清理所有活动的通知
|
||||
for (const [id, notification] of activeNotifications) {
|
||||
try {
|
||||
notification.close();
|
||||
} catch {}
|
||||
clearNotificationState(id);
|
||||
}
|
||||
|
||||
// 清理头像文件缓存
|
||||
try {
|
||||
await avatarFileCache.clearCache();
|
||||
} catch {}
|
||||
|
||||
console.log("[LinuxNotification] Service shutdown complete");
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ export class WcdbCore {
|
||||
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly hardlinkCacheMaxEntries = 20000
|
||||
private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null
|
||||
private mediaStreamSessionCacheAt = 0
|
||||
private readonly mediaStreamSessionCacheTtlMs = 12 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
private lastLogTail: string | null = null
|
||||
private lastResolvedLogPath: string | null = null
|
||||
@@ -277,7 +280,9 @@ export class WcdbCore {
|
||||
const isLinux = process.platform === 'linux'
|
||||
const isArm64 = process.arch === 'arm64'
|
||||
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
|
||||
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
||||
const legacySubDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
||||
const platformDir = isMac ? 'macos' : (isLinux ? 'linux' : 'win32')
|
||||
const archDir = isMac ? 'universal' : (isArm64 ? 'arm64' : 'x64')
|
||||
|
||||
const envDllPath = process.env.WCDB_DLL_PATH
|
||||
if (envDllPath && envDllPath.length > 0) {
|
||||
@@ -287,20 +292,33 @@ export class WcdbCore {
|
||||
// 基础路径探测
|
||||
const isPackaged = typeof process['resourcesPath'] !== 'undefined'
|
||||
const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources')
|
||||
|
||||
const candidates = [
|
||||
// 环境变量指定 resource 目录
|
||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
|
||||
// 显式 setPaths 设置的路径
|
||||
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
|
||||
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||
join(resourcesPath, 'resources', subDir, libName),
|
||||
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
|
||||
join(resourcesPath, subDir, libName),
|
||||
// CWD fallback
|
||||
join(process.cwd(), 'resources', subDir, libName)
|
||||
const roots = [
|
||||
process.env.WCDB_RESOURCES_PATH || null,
|
||||
this.resourcesPath || null,
|
||||
join(resourcesPath, 'resources'),
|
||||
resourcesPath,
|
||||
join(process.cwd(), 'resources')
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
const normalizedArch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const relativeCandidates = [
|
||||
join('wcdb', platformDir, archDir, libName),
|
||||
join('wcdb', platformDir, normalizedArch, libName),
|
||||
join('wcdb', platformDir, 'x64', libName),
|
||||
join('wcdb', platformDir, 'universal', libName),
|
||||
join('wcdb', platformDir, libName)
|
||||
]
|
||||
|
||||
const candidates: string[] = []
|
||||
for (const root of roots) {
|
||||
for (const relativePath of relativeCandidates) {
|
||||
candidates.push(join(root, relativePath))
|
||||
}
|
||||
// 兼容旧目录:resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||
candidates.push(join(root, legacySubDir, libName))
|
||||
candidates.push(join(root, libName))
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
@@ -1465,6 +1483,11 @@ export class WcdbCore {
|
||||
this.videoHardlinkCache.clear()
|
||||
}
|
||||
|
||||
private clearMediaStreamSessionCache(): void {
|
||||
this.mediaStreamSessionCache = null
|
||||
this.mediaStreamSessionCacheAt = 0
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ensureReady()
|
||||
}
|
||||
@@ -1580,6 +1603,7 @@ export class WcdbCore {
|
||||
this.currentDbStoragePath = null
|
||||
this.initialized = false
|
||||
this.clearHardlinkCaches()
|
||||
this.clearMediaStreamSessionCache()
|
||||
this.stopLogPolling()
|
||||
}
|
||||
}
|
||||
@@ -1957,7 +1981,7 @@ export class WcdbCore {
|
||||
error?: string
|
||||
}> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' }
|
||||
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' }
|
||||
try {
|
||||
const toInt = (value: unknown): number => {
|
||||
const n = Number(value || 0)
|
||||
@@ -2168,37 +2192,64 @@ export class WcdbCore {
|
||||
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 getSessionRows = async (): Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }>
|
||||
error?: string
|
||||
}> => {
|
||||
const now = Date.now()
|
||||
const cachedRows = this.mediaStreamSessionCache
|
||||
if (
|
||||
cachedRows &&
|
||||
now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs
|
||||
) {
|
||||
return { success: true, rows: cachedRows }
|
||||
}
|
||||
|
||||
const sessionsRes = await this.getSessions()
|
||||
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
|
||||
return { success: false, error: sessionsRes.error || '读取会话失败' }
|
||||
}
|
||||
|
||||
const rows = (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)
|
||||
|
||||
this.mediaStreamSessionCache = rows
|
||||
this.mediaStreamSessionCacheAt = now
|
||||
return { success: true, rows }
|
||||
}
|
||||
|
||||
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)
|
||||
let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = []
|
||||
if (requestedSessionId) {
|
||||
sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }]
|
||||
} else {
|
||||
const sessionsRowsRes = await getSessionRows()
|
||||
if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
|
||||
return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
|
||||
}
|
||||
sessionRows = sessionsRowsRes.rows
|
||||
}
|
||||
|
||||
const sessionRows = requestedSessionId
|
||||
? sessions.filter((row) => row.sessionId === requestedSessionId)
|
||||
: sessions
|
||||
if (sessionRows.length === 0) {
|
||||
return { success: true, items: [], hasMore: false, nextOffset: offset }
|
||||
}
|
||||
@@ -2219,10 +2270,10 @@ export class WcdbCore {
|
||||
outHasMore
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `扫描媒体流失败: ${result}` }
|
||||
return { success: false, error: `扫描资源失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析媒体流失败' }
|
||||
if (!jsonStr) return { success: false, error: '解析资源失败' }
|
||||
const rows = JSON.parse(jsonStr)
|
||||
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
|
||||
|
||||
@@ -2254,19 +2305,39 @@ export class WcdbCore {
|
||||
rawMessageContent &&
|
||||
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
|
||||
)
|
||||
const content = useRawMessageContent
|
||||
? rawMessageContent
|
||||
: decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||
const decodeContentIfNeeded = (): string => {
|
||||
if (useRawMessageContent) return rawMessageContent
|
||||
if (!rawMessageContent && !rawCompressContent) return ''
|
||||
return 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
|
||||
|
||||
let content = ''
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
|
||||
if (localType === 3) {
|
||||
imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
||||
imageDatName = extractImageDatName(row, '') || undefined
|
||||
if (!imageMd5 || !imageDatName) {
|
||||
content = decodeContentIfNeeded()
|
||||
if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined
|
||||
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
|
||||
}
|
||||
} else if (localType === 43) {
|
||||
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
||||
if (!videoMd5) {
|
||||
content = decodeContentIfNeeded()
|
||||
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
|
||||
} else if (useRawMessageContent) {
|
||||
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
|
||||
content = rawMessageContent
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
|
||||
@@ -2280,7 +2351,7 @@ export class WcdbCore {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
content: content || undefined
|
||||
content: localType === 43 ? (content || undefined) : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user