mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
8 Commits
@@ -10,7 +10,7 @@
|
||||
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
|
||||
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
|
||||
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ%20Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
|
||||
<img src="https://img.shields.io/badge/Vue.js-4FC08D?logo=Vue.js&logoColor=white" alt="Vue.js" />
|
||||
<img src="https://img.shields.io/badge/SQLite-003B57?logo=SQLite&logoColor=white" alt="SQLite" />
|
||||
|
||||
+112
-1
@@ -1359,6 +1359,34 @@ function getRendererConsoleLogPath() {
|
||||
}
|
||||
}
|
||||
|
||||
function getRendererDebugLogPath() {
|
||||
try {
|
||||
const dir = app.getPath("userData");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, "renderer-debug.log");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendRendererDebugLog(line) {
|
||||
const logPath = getRendererDebugLogPath();
|
||||
if (!logPath) return;
|
||||
try {
|
||||
fs.appendFileSync(logPath, line, { encoding: "utf8" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stringifyDebugDetails(details) {
|
||||
if (details == null) return "";
|
||||
if (typeof details === "string") return details;
|
||||
try {
|
||||
return JSON.stringify(details);
|
||||
} catch (err) {
|
||||
return `[unserializable:${err?.message || err}]`;
|
||||
}
|
||||
}
|
||||
|
||||
function setupRendererConsoleLogging(win) {
|
||||
if (!debugEnabled()) return;
|
||||
|
||||
@@ -1380,6 +1408,62 @@ function setupRendererConsoleLogging(win) {
|
||||
});
|
||||
}
|
||||
|
||||
function setupRendererLifecycleLogging(win) {
|
||||
if (!debugEnabled()) return;
|
||||
|
||||
const logRendererLifecycle = (message) => {
|
||||
logMain(`[renderer] ${message}`);
|
||||
};
|
||||
|
||||
logRendererLifecycle(`window-created id=${win.id}`);
|
||||
|
||||
win.webContents.on("did-start-loading", () => {
|
||||
logRendererLifecycle("did-start-loading");
|
||||
});
|
||||
|
||||
win.webContents.on("dom-ready", () => {
|
||||
logRendererLifecycle(`dom-ready url=${win.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
win.webContents.on("did-stop-loading", () => {
|
||||
logRendererLifecycle("did-stop-loading");
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
logRendererLifecycle(`did-finish-load url=${win.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
logRendererLifecycle(
|
||||
`did-fail-load code=${errorCode} mainFrame=${!!isMainFrame} url=${validatedURL} error=${errorDescription}`
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate", (_event, url, httpResponseCode, httpStatusText) => {
|
||||
logRendererLifecycle(
|
||||
`did-navigate url=${url} code=${httpResponseCode || 0} status=${httpStatusText || ""}`
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
|
||||
logRendererLifecycle(`did-navigate-in-page mainFrame=${!!isMainFrame} url=${url}`);
|
||||
});
|
||||
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
logRendererLifecycle(
|
||||
`render-process-gone reason=${details?.reason || ""} exitCode=${details?.exitCode ?? ""}`
|
||||
);
|
||||
});
|
||||
|
||||
win.on("unresponsive", () => {
|
||||
logRendererLifecycle("window-unresponsive");
|
||||
});
|
||||
|
||||
win.on("responsive", () => {
|
||||
logRendererLifecycle("window-responsive");
|
||||
});
|
||||
}
|
||||
|
||||
function createMainWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -1423,18 +1507,26 @@ function createMainWindow() {
|
||||
});
|
||||
|
||||
setupRendererConsoleLogging(win);
|
||||
setupRendererLifecycleLogging(win);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
async function loadWithRetry(win, url) {
|
||||
const startedAt = Date.now();
|
||||
let attempt = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
logMain(`[main] loadWithRetry attempt=${attempt} url=${url}`);
|
||||
try {
|
||||
await win.loadURL(url);
|
||||
logMain(`[main] loadWithRetry success attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url}`);
|
||||
return;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logMain(
|
||||
`[main] loadWithRetry failure attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url} error=${err?.message || err}`
|
||||
);
|
||||
if (Date.now() - startedAt > 60_000) throw new Error(`Failed to load URL in time: ${url}`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
@@ -1502,6 +1594,24 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isDebugEnabled", () => {
|
||||
try {
|
||||
return debugEnabled();
|
||||
} catch (err) {
|
||||
logMain(`[main] app:isDebugEnabled failed: ${err?.message || err}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("debug:log", (event, payload) => {
|
||||
const scope = String(payload?.scope || "renderer").trim() || "renderer";
|
||||
const message = String(payload?.message || "").trim() || "(empty)";
|
||||
const url = String(payload?.url || event?.sender?.getURL?.() || "").trim();
|
||||
const details = stringifyDebugDetails(payload?.details);
|
||||
const suffix = details ? ` details=${details}` : "";
|
||||
appendRendererDebugLog(`[${nowIso()}] [${scope}] ${message} url=${url}${suffix}\n`);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
|
||||
try {
|
||||
const next = setCloseBehavior(behavior);
|
||||
@@ -1727,6 +1837,7 @@ async function main() {
|
||||
process.env.ELECTRON_START_URL ||
|
||||
(app.isPackaged ? getBackendUiUrl() : "http://localhost:3000");
|
||||
|
||||
logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`);
|
||||
await loadWithRetry(win, startUrl);
|
||||
|
||||
// Auto-check updates after the UI has loaded (packaged builds only).
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
function sendDebugLog(scope, message, details) {
|
||||
try {
|
||||
ipcRenderer.send("debug:log", {
|
||||
scope: String(scope || "renderer"),
|
||||
message: String(message || ""),
|
||||
details: details == null ? {} : details,
|
||||
url: typeof location !== "undefined" ? String(location.href || "") : "",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sendDebugLog("preload", "script-start", {
|
||||
userAgent: typeof navigator !== "undefined" ? String(navigator.userAgent || "") : "",
|
||||
});
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("readystatechange", () => {
|
||||
sendDebugLog("preload", "document-readystate", {
|
||||
readyState: String(document.readyState || ""),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
sendDebugLog("preload", "dom-content-loaded");
|
||||
});
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
sendDebugLog("preload", "window-load");
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
sendDebugLog("preload", "window-error", {
|
||||
message: String(event?.message || ""),
|
||||
filename: String(event?.filename || ""),
|
||||
lineno: Number(event?.lineno || 0),
|
||||
colno: Number(event?.colno || 0),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event?.reason;
|
||||
sendDebugLog("preload", "window-unhandledrejection", {
|
||||
reason:
|
||||
reason instanceof Error
|
||||
? {
|
||||
name: String(reason.name || "Error"),
|
||||
message: String(reason.message || ""),
|
||||
stack: String(reason.stack || ""),
|
||||
}
|
||||
: String(reason || ""),
|
||||
});
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
sendDebugLog("preload", "set-timeout-0");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
// Marker used by the frontend to distinguish the Electron desktop shell from the pure web build.
|
||||
__brand: "WeChatDataAnalysisDesktop",
|
||||
@@ -7,6 +67,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
|
||||
close: () => ipcRenderer.invoke("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
|
||||
isDebugEnabled: () => ipcRenderer.invoke("app:isDebugEnabled"),
|
||||
logDebug: (scope, message, details = {}) => sendDebugLog(scope, message, details),
|
||||
|
||||
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
|
||||
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { defineComponent, h, ref, watch } from 'vue'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -19,7 +19,15 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const fromAvatarImgOk = ref(false)
|
||||
const fromAvatarImgError = ref(false)
|
||||
const lastFromAvatarUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => String(props.fromAvatar || '').trim(),
|
||||
() => {
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const getFromText = () => {
|
||||
const raw = String(props.from || '').trim()
|
||||
@@ -47,12 +55,6 @@ export default defineComponent({
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
lastFromAvatarUrl.value = fromAvatarUrl
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
}
|
||||
|
||||
const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value
|
||||
const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value)
|
||||
const fromAvatarStyle = fromAvatarImgOk.value
|
||||
|
||||
@@ -27,13 +27,41 @@ export const useChatMessages = ({
|
||||
const messageContainerRef = ref(null)
|
||||
const activeMessagesFor = ref('')
|
||||
const showJumpToBottom = ref(false)
|
||||
let lastRenderMessagesFingerprint = ''
|
||||
|
||||
const isDesktopRenderer = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const logMessagePhase = (phase, details = {}) => {
|
||||
if (!isDesktopRenderer()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-messages', phase, details)
|
||||
} catch {}
|
||||
console.info(`[chat-messages] ${phase}`, {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||||
activeMessagesFor: String(activeMessagesFor.value || '').trim(),
|
||||
...details
|
||||
})
|
||||
}
|
||||
|
||||
const summarizeRenderTypes = (list) => {
|
||||
const counts = {}
|
||||
for (const item of Array.isArray(list) ? list : []) {
|
||||
const key = String(item?.renderType || 'unknown').trim() || 'unknown'
|
||||
counts[key] = Number(counts[key] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const previewImageUrl = ref(null)
|
||||
const previewVideoUrl = ref(null)
|
||||
const previewVideoPosterUrl = ref('')
|
||||
const previewVideoError = ref('')
|
||||
|
||||
const voiceRefs = ref({})
|
||||
const voiceRefs = new Map()
|
||||
const currentPlayingVoice = ref(null)
|
||||
const playingVoiceId = ref(null)
|
||||
|
||||
@@ -113,8 +141,16 @@ export const useChatMessages = ({
|
||||
const renderMessages = computed(() => {
|
||||
const list = messages.value || []
|
||||
const reverseSides = !!reverseMessageSides.value
|
||||
const fingerprint = `${String(selectedContact.value?.username || '').trim()}:${list.length}:${reverseSides ? '1' : '0'}`
|
||||
const shouldLogRender = isDesktopRenderer() && fingerprint !== lastRenderMessagesFingerprint
|
||||
if (shouldLogRender) {
|
||||
logMessagePhase('renderMessages:start', {
|
||||
count: list.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
let previousTs = 0
|
||||
return list.map((message) => {
|
||||
const rendered = list.map((message) => {
|
||||
const ts = Number(message.createTime || 0)
|
||||
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
|
||||
if (ts) previousTs = ts
|
||||
@@ -127,6 +163,14 @@ export const useChatMessages = ({
|
||||
timeDivider: formatTimeDivider(ts)
|
||||
}
|
||||
})
|
||||
if (shouldLogRender) {
|
||||
lastRenderMessagesFingerprint = fingerprint
|
||||
logMessagePhase('renderMessages:end', {
|
||||
count: rendered.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
return rendered
|
||||
})
|
||||
|
||||
const updateJumpToBottomState = () => {
|
||||
@@ -195,18 +239,16 @@ export const useChatMessages = ({
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return
|
||||
if (element) {
|
||||
voiceRefs.value = { ...voiceRefs.value, [key]: element }
|
||||
} else if (voiceRefs.value[key]) {
|
||||
const next = { ...voiceRefs.value }
|
||||
delete next[key]
|
||||
voiceRefs.value = next
|
||||
voiceRefs.set(key, element)
|
||||
} else {
|
||||
voiceRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const playVoiceById = async (voiceId) => {
|
||||
const key = String(voiceId || '').trim()
|
||||
if (!key) return
|
||||
const audio = voiceRefs.value[key]
|
||||
const audio = voiceRefs.get(key)
|
||||
if (!audio) return
|
||||
|
||||
try {
|
||||
@@ -333,6 +375,10 @@ export const useChatMessages = ({
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
logMessagePhase('loadMessages:enter', {
|
||||
username,
|
||||
reset
|
||||
})
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
activeMessagesFor.value = username
|
||||
@@ -357,13 +403,48 @@ export const useChatMessages = ({
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
logMessagePhase('loadMessages:request:start', {
|
||||
username,
|
||||
reset,
|
||||
offset,
|
||||
existingCount: existing.length,
|
||||
renderTypeFilter: messageTypeFilter.value,
|
||||
realtime: !!realtimeEnabled.value
|
||||
})
|
||||
const response = await api.listChatMessages(params)
|
||||
logMessagePhase('loadMessages:request:end', {
|
||||
username,
|
||||
reset,
|
||||
rawCount: Array.isArray(response?.messages) ? response.messages.length : 0,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
const raw = response?.messages || []
|
||||
logMessagePhase('loadMessages:normalize:start', {
|
||||
username,
|
||||
rawCount: raw.length
|
||||
})
|
||||
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
|
||||
logMessagePhase('loadMessages:normalize:end', {
|
||||
username,
|
||||
mappedCount: mapped.length,
|
||||
renderTypeCounts: summarizeRenderTypes(mapped)
|
||||
})
|
||||
|
||||
if (activeMessagesFor.value !== username) return
|
||||
if (activeMessagesFor.value !== username) {
|
||||
logMessagePhase('loadMessages:abort-stale', {
|
||||
username,
|
||||
activeMessagesFor: activeMessagesFor.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logMessagePhase('loadMessages:state-commit:start', {
|
||||
username,
|
||||
reset,
|
||||
mappedCount: mapped.length
|
||||
})
|
||||
if (reset) {
|
||||
allMessages.value = { ...allMessages.value, [username]: mapped }
|
||||
} else {
|
||||
@@ -380,6 +461,10 @@ export const useChatMessages = ({
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:state-commit:end', {
|
||||
username,
|
||||
storedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
|
||||
messagesMeta.value = {
|
||||
...messagesMeta.value,
|
||||
@@ -388,8 +473,20 @@ export const useChatMessages = ({
|
||||
hasMore: response?.hasMore
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:meta-commit:end', {
|
||||
username,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
logMessagePhase('loadMessages:nextTick:start', {
|
||||
username
|
||||
})
|
||||
await nextTick()
|
||||
logMessagePhase('loadMessages:nextTick:end', {
|
||||
username,
|
||||
renderedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer) {
|
||||
if (reset) {
|
||||
@@ -400,10 +497,28 @@ export const useChatMessages = ({
|
||||
}
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
logMessagePhase('loadMessages:scroll:end', {
|
||||
username,
|
||||
hasContainer: !!nextContainer,
|
||||
scrollTop: nextContainer ? nextContainer.scrollTop : null,
|
||||
scrollHeight: nextContainer ? nextContainer.scrollHeight : null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[chat-messages] loadMessages:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
reset: !!reset,
|
||||
error
|
||||
})
|
||||
messagesError.value = error?.message || '加载聊天记录失败'
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
logMessagePhase('loadMessages:exit', {
|
||||
username,
|
||||
reset,
|
||||
loading: isLoadingMessages.value,
|
||||
error: messagesError.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +606,18 @@ export const useChatMessages = ({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const clearVoicePlaybackState = () => {
|
||||
try {
|
||||
currentPlayingVoice.value?.pause?.()
|
||||
if (currentPlayingVoice.value) currentPlayingVoice.value.currentTime = 0
|
||||
} catch {}
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
voiceRefs.clear()
|
||||
}
|
||||
|
||||
const resetMessageState = () => {
|
||||
clearVoicePlaybackState()
|
||||
allMessages.value = {}
|
||||
messagesMeta.value = {}
|
||||
messagesError.value = ''
|
||||
@@ -700,6 +826,7 @@ export const useChatMessages = ({
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = null
|
||||
clearContactProfileHoverHideTimer()
|
||||
clearVoicePlaybackState()
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -58,6 +58,67 @@ const routeUsername = computed(() => {
|
||||
return (Array.isArray(raw) ? raw[0] : raw) || ''
|
||||
})
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const desktopDebugEnabled = ref(false)
|
||||
const chatBootstrapStartedAt = process.client && typeof performance !== 'undefined' ? performance.now() : 0
|
||||
let messageLoadSequence = 0
|
||||
let firstSelectContactLogged = false
|
||||
let firstLoadMessagesLogged = false
|
||||
|
||||
const resolveDesktopDebugEnabled = async () => {
|
||||
if (!isDesktopShell() || typeof window.wechatDesktop?.isDebugEnabled !== 'function') {
|
||||
desktopDebugEnabled.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
desktopDebugEnabled.value = !!(await window.wechatDesktop.isDebugEnabled())
|
||||
} catch {
|
||||
desktopDebugEnabled.value = false
|
||||
}
|
||||
|
||||
return desktopDebugEnabled.value
|
||||
}
|
||||
|
||||
const chatBootstrapElapsedMs = () => {
|
||||
if (!process.client || typeof performance === 'undefined') return null
|
||||
const elapsed = performance.now() - chatBootstrapStartedAt
|
||||
return Number.isFinite(elapsed) ? Number(elapsed.toFixed(1)) : null
|
||||
}
|
||||
|
||||
const shouldLogChatBootstrap = () => isDesktopShell() || desktopDebugEnabled.value
|
||||
|
||||
const logChatBootstrap = (phase, details = {}) => {
|
||||
if (!shouldLogChatBootstrap()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-bootstrap', phase, details)
|
||||
} catch {}
|
||||
console.info(`[chat-bootstrap] ${phase}`, {
|
||||
elapsedMs: chatBootstrapElapsedMs(),
|
||||
route: route.fullPath,
|
||||
...details
|
||||
})
|
||||
}
|
||||
|
||||
const waitForNextPaint = async () => {
|
||||
await nextTick()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
await new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.setTimeout(resolve, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const nextMessageLoadToken = () => {
|
||||
messageLoadSequence += 1
|
||||
return messageLoadSequence
|
||||
}
|
||||
|
||||
const buildChatPath = (username) => {
|
||||
return username ? `/chat/${encodeURIComponent(username)}` : '/chat'
|
||||
}
|
||||
@@ -184,17 +245,83 @@ const {
|
||||
|
||||
let exitSearchContext = async () => {}
|
||||
|
||||
const runMessageLoad = async ({ username, reset = true, deferUntilPaint = false, reason = '', token = nextMessageLoadToken() } = {}) => {
|
||||
const nextUsername = String(username || '').trim()
|
||||
if (!nextUsername) return false
|
||||
|
||||
if (deferUntilPaint) {
|
||||
logChatBootstrap('loadMessages:scheduled', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token
|
||||
})
|
||||
await waitForNextPaint()
|
||||
if (token !== messageLoadSequence) {
|
||||
logChatBootstrap('loadMessages:skipped-stale', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstLoad = !firstLoadMessagesLogged
|
||||
if (isFirstLoad) {
|
||||
firstLoadMessagesLogged = true
|
||||
}
|
||||
|
||||
logChatBootstrap(isFirstLoad ? 'loadMessages:first:start' : 'loadMessages:start', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token,
|
||||
reset
|
||||
})
|
||||
|
||||
await loadMessages({ username: nextUsername, reset })
|
||||
|
||||
logChatBootstrap(isFirstLoad ? 'loadMessages:first:end' : 'loadMessages:end', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token,
|
||||
renderedMessages: messages.value.length
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const selectContact = async (contact, options = {}) => {
|
||||
if (!contact) return
|
||||
const selectionReason = String(options.reason || 'manual-select').trim() || 'manual-select'
|
||||
const loadToken = nextMessageLoadToken()
|
||||
const nextUsername = contact?.username || ''
|
||||
if (searchContext.value?.active && searchContext.value.username && searchContext.value.username !== nextUsername) {
|
||||
await exitSearchContext()
|
||||
}
|
||||
|
||||
const isFirstSelect = !firstSelectContactLogged
|
||||
if (isFirstSelect) {
|
||||
firstSelectContactLogged = true
|
||||
}
|
||||
logChatBootstrap(isFirstSelect ? 'selectContact:first' : 'selectContact', {
|
||||
username: nextUsername,
|
||||
reason: selectionReason,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
skipLoadMessages: !!options.skipLoadMessages,
|
||||
syncRoute: options.syncRoute !== false
|
||||
})
|
||||
|
||||
selectedContact.value = contact
|
||||
if (!nextUsername) return
|
||||
|
||||
if (!options.skipLoadMessages) {
|
||||
loadMessages({ username: nextUsername, reset: true })
|
||||
void runMessageLoad({
|
||||
username: nextUsername,
|
||||
reset: true,
|
||||
deferUntilPaint: !!options.deferLoadMessages,
|
||||
reason: selectionReason,
|
||||
token: loadToken
|
||||
})
|
||||
}
|
||||
|
||||
if (options.syncRoute !== false && nextUsername) {
|
||||
@@ -205,24 +332,34 @@ const selectContact = async (contact, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const applyRouteSelection = async () => {
|
||||
const applyRouteSelection = async (options = {}) => {
|
||||
if (!contacts.value || contacts.value.length === 0) {
|
||||
selectedContact.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const selectionReason = String(options.reason || 'route-selection').trim() || 'route-selection'
|
||||
const requested = routeUsername.value || ''
|
||||
if (requested) {
|
||||
const matched = contacts.value.find((contact) => contact.username === requested)
|
||||
if (matched) {
|
||||
if (selectedContact.value?.username !== matched.username) {
|
||||
await selectContact(matched, { syncRoute: false })
|
||||
await selectContact(matched, {
|
||||
syncRoute: false,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
reason: `${selectionReason}:matched-route`
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await selectContact(contacts.value[0], { syncRoute: true, replaceRoute: true })
|
||||
await selectContact(contacts.value[0], {
|
||||
syncRoute: true,
|
||||
replaceRoute: true,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
reason: `${selectionReason}:fallback-first-contact`
|
||||
})
|
||||
}
|
||||
|
||||
const searchState = useChatSearch({
|
||||
@@ -363,6 +500,9 @@ const queueRealtimeSessionsRefresh = () => {
|
||||
}
|
||||
|
||||
const onAccountChange = async () => {
|
||||
logChatBootstrap('accountChange:start', {
|
||||
selectedAccount: selectedAccount.value
|
||||
})
|
||||
try {
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
@@ -374,7 +514,18 @@ const onAccountChange = async () => {
|
||||
}
|
||||
|
||||
resetAccountScopedState()
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('accountChange:applyRouteSelection:start', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
await applyRouteSelection({
|
||||
reason: 'account-change'
|
||||
})
|
||||
logChatBootstrap('accountChange:end', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
selectedUsername: selectedContact.value?.username || '',
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
}
|
||||
|
||||
const onGlobalClick = (event) => {
|
||||
@@ -420,6 +571,13 @@ const onGlobalKeyDown = (event) => {
|
||||
onMounted(async () => {
|
||||
if (!process.client) return
|
||||
|
||||
await resolveDesktopDebugEnabled()
|
||||
logChatBootstrap('route mount start', {
|
||||
requestedUsername: routeUsername.value,
|
||||
selectedAccount: selectedAccount.value,
|
||||
desktopShell: isDesktopShell()
|
||||
})
|
||||
|
||||
document.addEventListener('click', onGlobalClick)
|
||||
document.addEventListener('keydown', onGlobalKeyDown)
|
||||
document.addEventListener('mousemove', onFloatingWindowMouseMove)
|
||||
@@ -428,9 +586,43 @@ onMounted(async () => {
|
||||
document.addEventListener('touchend', onFloatingWindowMouseUp)
|
||||
document.addEventListener('touchcancel', onFloatingWindowMouseUp)
|
||||
|
||||
logChatBootstrap('loadContacts:start', {
|
||||
selectedAccount: selectedAccount.value
|
||||
})
|
||||
await loadContacts()
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('loadContacts:end', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
|
||||
const deferInitialConversationBoot = isDesktopShell()
|
||||
await waitForNextPaint()
|
||||
logChatBootstrap('first render completion', {
|
||||
contactCount: contacts.value.length,
|
||||
deferInitialConversationBoot
|
||||
})
|
||||
|
||||
logChatBootstrap('applyRouteSelection:start', {
|
||||
requestedUsername: routeUsername.value,
|
||||
deferLoadMessages: deferInitialConversationBoot
|
||||
})
|
||||
await applyRouteSelection({
|
||||
deferLoadMessages: deferInitialConversationBoot,
|
||||
reason: deferInitialConversationBoot ? 'initial-route-post-paint' : 'initial-route'
|
||||
})
|
||||
logChatBootstrap('applyRouteSelection:end', {
|
||||
selectedUsername: selectedContact.value?.username || '',
|
||||
requestedUsername: routeUsername.value
|
||||
})
|
||||
|
||||
logChatBootstrap('tryEnableRealtimeAuto:start', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
realtimeEnabled: realtimeEnabled.value
|
||||
})
|
||||
await tryEnableRealtimeAuto()
|
||||
logChatBootstrap('tryEnableRealtimeAuto:end', {
|
||||
realtimeEnabled: realtimeEnabled.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -488,11 +680,17 @@ watch(messageTypeFilter, async (next, prev) => {
|
||||
|
||||
watch(
|
||||
routeUsername,
|
||||
async () => {
|
||||
async (next, prev) => {
|
||||
if (!process.client) return
|
||||
if (isLoadingContacts.value) return
|
||||
if (!contacts.value.length) return
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('routeUsername:change', {
|
||||
previousUsername: prev || '',
|
||||
nextUsername: next || ''
|
||||
})
|
||||
await applyRouteSelection({
|
||||
reason: 'route-watch'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const isDesktopShell = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const formatError = (error) => {
|
||||
if (!error) return ''
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: String(error.name || 'Error'),
|
||||
message: String(error.message || ''),
|
||||
stack: String(error.stack || '')
|
||||
}
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(error))
|
||||
} catch {}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const logDesktopDebug = (phase, details = {}) => {
|
||||
if (!isDesktopShell()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('nuxt-bootstrap', phase, {
|
||||
href: String(window.location?.href || ''),
|
||||
...details
|
||||
})
|
||||
} catch {}
|
||||
try {
|
||||
console.info(`[nuxt-bootstrap] ${phase}`, details)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
logDesktopDebug('plugin:setup')
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('error', (event) => {
|
||||
logDesktopDebug('window:error', {
|
||||
message: String(event?.message || ''),
|
||||
filename: String(event?.filename || ''),
|
||||
lineno: Number(event?.lineno || 0),
|
||||
colno: Number(event?.colno || 0),
|
||||
error: formatError(event?.error)
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
logDesktopDebug('window:unhandledrejection', {
|
||||
reason: formatError(event?.reason)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
nuxtApp.hook('app:created', () => {
|
||||
logDesktopDebug('app:created')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:beforeMount', () => {
|
||||
logDesktopDebug('app:beforeMount')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
logDesktopDebug('app:mounted')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
logDesktopDebug('page:start')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
logDesktopDebug('page:finish')
|
||||
})
|
||||
|
||||
nuxtApp.hook('vue:error', (error, _instance, info) => {
|
||||
logDesktopDebug('vue:error', {
|
||||
info: String(info || ''),
|
||||
error: formatError(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,17 @@ logger = get_logger(__name__)
|
||||
|
||||
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
|
||||
_DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1"
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
@@ -34,7 +45,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
for p in _OUTPUT_DATABASES_DIR.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"):
|
||||
accounts.append(p.name)
|
||||
|
||||
accounts.sort()
|
||||
@@ -49,7 +60,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
detail="No decrypted databases found. Please decrypt first.",
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
selected = str(account or "").strip() or accounts[0]
|
||||
if selected not in accounts:
|
||||
raise HTTPException(status_code=404, detail="Account not found.")
|
||||
base = _OUTPUT_DATABASES_DIR.resolve()
|
||||
candidate = (_OUTPUT_DATABASES_DIR / selected).resolve()
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ logger = get_logger(__name__)
|
||||
|
||||
# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录)
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
@@ -36,7 +47,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
for p in output_db_dir.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"):
|
||||
accounts.append(p.name)
|
||||
|
||||
accounts.sort()
|
||||
@@ -53,7 +64,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
detail="No decrypted databases found. Please decrypt first.",
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
selected = str(account or "").strip() or accounts[0]
|
||||
if selected not in accounts:
|
||||
raise HTTPException(status_code=404, detail="Account not found.")
|
||||
base = output_db_dir.resolve()
|
||||
candidate = (output_db_dir / selected).resolve()
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..app_paths import get_output_databases_dir
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..key_store import upsert_account_keys_in_store
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -79,6 +79,8 @@ async def decrypt_databases(request: DecryptRequest):
|
||||
"account_results": results.get("account_results", {}),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"解密API异常: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -126,44 +128,17 @@ async def decrypt_databases_stream(
|
||||
yield _sse({"type": "scanning", "message": "正在扫描数据库文件..."})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
account_name = "unknown_account"
|
||||
path_parts = storage_path.parts
|
||||
account_patterns = ["wxid_"]
|
||||
for part in path_parts:
|
||||
for pattern in account_patterns:
|
||||
if part.startswith(pattern):
|
||||
parts = part.split("_")
|
||||
if len(parts) >= 3:
|
||||
account_name = "_".join(parts[:-1])
|
||||
else:
|
||||
account_name = part
|
||||
break
|
||||
if account_name != "unknown_account":
|
||||
break
|
||||
|
||||
if account_name == "unknown_account":
|
||||
for part in reversed(path_parts):
|
||||
if part != "db_storage" and len(part) > 3:
|
||||
account_name = part
|
||||
break
|
||||
|
||||
databases: list[dict] = []
|
||||
for root, _dirs, files in os.walk(storage_path):
|
||||
if "db_storage" not in str(root):
|
||||
continue
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append({"path": db_path, "name": file_name, "account": account_name})
|
||||
|
||||
if not databases:
|
||||
yield _sse({"type": "error", "message": "未找到微信数据库文件!请检查 db_storage_path 是否正确"})
|
||||
scan_result = scan_account_databases_from_path(p)
|
||||
if scan_result["status"] == "error":
|
||||
payload = {"type": "error", "message": scan_result["message"]}
|
||||
detected_accounts = scan_result.get("detected_accounts") or []
|
||||
if detected_accounts:
|
||||
payload["detected_accounts"] = detected_accounts
|
||||
yield _sse(payload)
|
||||
return
|
||||
|
||||
account_databases = {account_name: databases}
|
||||
account_databases = scan_result.get("account_databases", {})
|
||||
account_sources = scan_result.get("account_sources", {})
|
||||
total_databases = sum(len(dbs) for dbs in account_databases.values())
|
||||
|
||||
yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"})
|
||||
@@ -193,12 +168,9 @@ async def decrypt_databases_stream(
|
||||
|
||||
# Save a hint for later UI (same as non-stream endpoint).
|
||||
try:
|
||||
source_db_storage_path = p
|
||||
wxid_dir = ""
|
||||
if storage_path.name.lower() == "db_storage":
|
||||
wxid_dir = str(storage_path.parent)
|
||||
else:
|
||||
wxid_dir = str(storage_path)
|
||||
source_info = account_sources.get(account, {})
|
||||
source_db_storage_path = str(source_info.get("db_storage_path") or p)
|
||||
wxid_dir = str(source_info.get("wxid_dir") or "")
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps({"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -26,25 +26,41 @@ _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
|
||||
_WCDB_API_DLL_SELECTED: Optional[Path] = None
|
||||
|
||||
|
||||
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
|
||||
try:
|
||||
resolved = path.resolve(strict=False)
|
||||
except Exception:
|
||||
resolved = path
|
||||
|
||||
try:
|
||||
default_resolved = _DEFAULT_WCDB_API_DLL.resolve(strict=False)
|
||||
except Exception:
|
||||
default_resolved = _DEFAULT_WCDB_API_DLL
|
||||
|
||||
if resolved == default_resolved:
|
||||
return True
|
||||
|
||||
parts = tuple(str(part).lower() for part in resolved.parts)
|
||||
allowed_suffixes = (
|
||||
("backend", "native", "wcdb_api.dll"),
|
||||
("wechat_decrypt_tool", "native", "wcdb_api.dll"),
|
||||
)
|
||||
return any(parts[-len(suffix) :] == suffix for suffix in allowed_suffixes)
|
||||
|
||||
|
||||
def _candidate_wcdb_api_dll_paths() -> list[Path]:
|
||||
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
|
||||
"""Return allowed locations for wcdb_api.dll."""
|
||||
cands: list[Path] = []
|
||||
|
||||
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
|
||||
if env:
|
||||
cands.append(Path(env))
|
||||
env_path = Path(env)
|
||||
if _is_project_wcdb_api_dll_path(env_path):
|
||||
cands.append(env_path)
|
||||
else:
|
||||
logger.warning("[wcdb] ignore external wcdb_api.dll override: %s", env_path)
|
||||
|
||||
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
except Exception:
|
||||
repo_root = Path.cwd()
|
||||
|
||||
for p in [
|
||||
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
|
||||
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
|
||||
_DEFAULT_WCDB_API_DLL,
|
||||
]:
|
||||
for p in (_DEFAULT_WCDB_API_DLL,):
|
||||
if p not in cands:
|
||||
cands.append(p)
|
||||
|
||||
|
||||
@@ -27,6 +27,169 @@ from .app_paths import get_output_databases_dir
|
||||
# SQLite文件头
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _normalize_account_name(name: str) -> str:
|
||||
value = str(name or "").strip()
|
||||
if not value:
|
||||
return "unknown_account"
|
||||
|
||||
if value.startswith("wxid_"):
|
||||
parts = value.split("_")
|
||||
if len(parts) >= 3:
|
||||
trimmed = "_".join(parts[:-1]).strip()
|
||||
if trimmed:
|
||||
return trimmed
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _derive_account_name_from_path(path: Path) -> str:
|
||||
try:
|
||||
target = path.resolve()
|
||||
except Exception:
|
||||
target = path
|
||||
|
||||
for part in target.parts:
|
||||
part_str = str(part or "").strip()
|
||||
if part_str.startswith("wxid_"):
|
||||
return _normalize_account_name(part_str)
|
||||
|
||||
for part in reversed(target.parts):
|
||||
part_str = str(part or "").strip()
|
||||
if not part_str or part_str.lower() == "db_storage" or len(part_str) <= 3:
|
||||
continue
|
||||
return _normalize_account_name(part_str)
|
||||
|
||||
return "unknown_account"
|
||||
|
||||
|
||||
def _resolve_db_storage_roots(storage_path: Path) -> list[Path]:
|
||||
try:
|
||||
target = storage_path.resolve()
|
||||
except Exception:
|
||||
target = storage_path
|
||||
|
||||
if not target.exists():
|
||||
return []
|
||||
|
||||
current = target if target.is_dir() else target.parent
|
||||
probe = current
|
||||
while True:
|
||||
if probe.name.lower() == "db_storage":
|
||||
return [probe]
|
||||
parent = probe.parent
|
||||
if parent == probe:
|
||||
break
|
||||
probe = parent
|
||||
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
for root, dirs, _files in os.walk(current):
|
||||
root_path = Path(root)
|
||||
if root_path.name.lower() != "db_storage":
|
||||
continue
|
||||
roots.append(root_path)
|
||||
dirs[:] = []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
uniq: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
key = str(root)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
uniq.append(root)
|
||||
uniq.sort(key=lambda p: str(p).lower())
|
||||
return uniq
|
||||
|
||||
|
||||
def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
storage_path = Path(str(db_storage_path or "").strip())
|
||||
if not storage_path.exists():
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": [],
|
||||
}
|
||||
|
||||
db_roots = _resolve_db_storage_roots(storage_path)
|
||||
if not db_roots:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "未找到微信数据库文件!请确保路径指向具体账号的 db_storage 目录。",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": [],
|
||||
}
|
||||
|
||||
detected_accounts = [
|
||||
{
|
||||
"account": _derive_account_name_from_path(root),
|
||||
"db_storage_path": str(root),
|
||||
"wxid_dir": str(root.parent),
|
||||
}
|
||||
for root in db_roots
|
||||
]
|
||||
|
||||
if len(db_roots) > 1:
|
||||
account_names = ", ".join(
|
||||
[str(item.get("account") or item.get("db_storage_path") or "").strip() for item in detected_accounts]
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"检测到多个账号目录,请选择具体账号的 db_storage 目录后再解密,"
|
||||
f"不要直接选择上级目录。当前检测到: {account_names}"
|
||||
),
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
db_root = db_roots[0]
|
||||
account_name = _derive_account_name_from_path(db_root)
|
||||
databases: list[dict] = []
|
||||
for root, _dirs, files in os.walk(db_root):
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append(
|
||||
{
|
||||
"path": db_path,
|
||||
"name": file_name,
|
||||
"account": account_name,
|
||||
}
|
||||
)
|
||||
|
||||
if not databases:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "未找到微信数据库文件!请检查 db_storage_path 是否正确",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "",
|
||||
"account_databases": {account_name: databases},
|
||||
"account_sources": {
|
||||
account_name: {
|
||||
"db_storage_path": str(db_root),
|
||||
"wxid_dir": str(db_root.parent),
|
||||
}
|
||||
},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
def setup_logging():
|
||||
"""设置日志配置 - 已弃用,使用统一的日志配置"""
|
||||
from .logging_config import setup_logging as unified_setup_logging
|
||||
@@ -259,75 +422,28 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
|
||||
# 查找数据库文件并按账号组织
|
||||
account_databases = {} # {account_name: [db_info, ...]}
|
||||
account_sources = {}
|
||||
detected_accounts = []
|
||||
|
||||
if db_storage_path:
|
||||
# 使用指定路径查找数据库
|
||||
storage_path = Path(db_storage_path)
|
||||
|
||||
if storage_path.exists():
|
||||
# 尝试从路径中提取账号名
|
||||
account_name = "unknown_account"
|
||||
path_parts = storage_path.parts
|
||||
|
||||
# 常见的微信账号格式模式
|
||||
account_patterns = ['wxid_']
|
||||
|
||||
for part in path_parts:
|
||||
# 检查是否匹配已知的账号格式
|
||||
for pattern in account_patterns:
|
||||
if part.startswith(pattern):
|
||||
# 提取主要部分,去掉后面的随机后缀
|
||||
# 例如:wxid_v4mbduwqtzpt22_1e7a -> wxid_v4mbduwqtzpt22
|
||||
parts = part.split('_')
|
||||
if len(parts) >= 3: # wxid_主要部分_随机后缀
|
||||
account_name = '_'.join(parts[:-1]) # 去掉最后一个随机部分
|
||||
else:
|
||||
account_name = part # 如果格式不符合预期,保留原名
|
||||
break
|
||||
if account_name != "unknown_account":
|
||||
break
|
||||
|
||||
# 如果没有匹配到已知格式,使用包含数据库的目录名
|
||||
if account_name == "unknown_account":
|
||||
# 查找包含db_storage的父目录作为账号名
|
||||
for part in reversed(path_parts):
|
||||
if part != "db_storage" and len(part) > 3:
|
||||
account_name = part
|
||||
break
|
||||
|
||||
databases = []
|
||||
# 使用递归查找,与自动检测逻辑一致
|
||||
for root, dirs, files in os.walk(storage_path):
|
||||
# 只处理db_storage目录下的数据库文件
|
||||
if "db_storage" not in str(root):
|
||||
continue
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
# 排除不需要解密的数据库
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append({
|
||||
'path': db_path,
|
||||
'name': file_name,
|
||||
'account': account_name
|
||||
})
|
||||
|
||||
if databases:
|
||||
account_databases[account_name] = databases
|
||||
logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件")
|
||||
else:
|
||||
scan_result = scan_account_databases_from_path(db_storage_path)
|
||||
detected_accounts = scan_result.get("detected_accounts", [])
|
||||
if scan_result["status"] == "error":
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
"message": scan_result["message"],
|
||||
"total_databases": 0,
|
||||
"successful_count": 0,
|
||||
"failed_count": 0,
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": [],
|
||||
"failed_files": []
|
||||
"failed_files": [],
|
||||
"detected_accounts": scan_result.get("detected_accounts", []),
|
||||
}
|
||||
account_databases = scan_result.get("account_databases", {})
|
||||
account_sources = scan_result.get("account_sources", {})
|
||||
for account_name, databases in account_databases.items():
|
||||
logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件")
|
||||
else:
|
||||
# 不再支持自动检测,要求用户提供具体的db_storage_path
|
||||
return {
|
||||
@@ -387,14 +503,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
logger.info(f"账号 {account_name} 输出目录: {account_output_dir}")
|
||||
|
||||
try:
|
||||
source_db_storage_path = str(db_storage_path or "")
|
||||
wxid_dir = ""
|
||||
if db_storage_path:
|
||||
sp = Path(db_storage_path)
|
||||
if sp.name.lower() == "db_storage":
|
||||
wxid_dir = str(sp.parent)
|
||||
else:
|
||||
wxid_dir = str(sp)
|
||||
source_info = account_sources.get(account_name, {})
|
||||
source_db_storage_path = str(source_info.get("db_storage_path") or db_storage_path or "")
|
||||
wxid_dir = str(source_info.get("wxid_dir") or "")
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -473,7 +584,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": processed_files,
|
||||
"failed_files": failed_files,
|
||||
"account_results": account_results # 新增:按账号的详细结果
|
||||
"account_results": account_results, # 新增:按账号的详细结果
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool import wcdb_realtime
|
||||
|
||||
|
||||
class TestWcdbRealtimeDllPathSelection(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
wcdb_realtime._WCDB_API_DLL_SELECTED = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
wcdb_realtime._WCDB_API_DLL_SELECTED = None
|
||||
|
||||
def test_resolve_prefers_project_dll_over_weflow(self) -> None:
|
||||
weflow_dll = ROOT / "WeFlow" / "resources" / "wcdb_api.dll"
|
||||
self.assertTrue(weflow_dll.exists())
|
||||
self.assertTrue(wcdb_realtime._DEFAULT_WCDB_API_DLL.exists())
|
||||
|
||||
with patch.dict(os.environ, {"WECHAT_TOOL_WCDB_API_DLL_PATH": str(weflow_dll)}, clear=False):
|
||||
resolved = wcdb_realtime._resolve_wcdb_api_dll_path()
|
||||
|
||||
self.assertEqual(
|
||||
resolved.resolve(),
|
||||
wcdb_realtime._DEFAULT_WCDB_API_DLL.resolve(),
|
||||
)
|
||||
|
||||
def test_resolve_accepts_project_packaged_override(self) -> None:
|
||||
packaged_dll = ROOT / "desktop" / "resources" / "backend" / "native" / "wcdb_api.dll"
|
||||
self.assertTrue(packaged_dll.exists())
|
||||
|
||||
with patch.dict(os.environ, {"WECHAT_TOOL_WCDB_API_DLL_PATH": str(packaged_dll)}, clear=False):
|
||||
resolved = wcdb_realtime._resolve_wcdb_api_dll_path()
|
||||
|
||||
self.assertEqual(resolved.resolve(), packaged_dll.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user