From 64c3e5e826101fab9d1b1e2fff8f5205d7615b01 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Mon, 16 Mar 2026 15:39:11 +0800 Subject: [PATCH] fix(chat): add packaged freeze diagnostics --- desktop/src/main.cjs | 76 ++++++- desktop/src/preload.cjs | 1 + frontend/composables/chat/useChatMessages.js | 108 +++++++++- frontend/pages/chat/[[username]].vue | 211 ++++++++++++++++++- 4 files changed, 385 insertions(+), 11 deletions(-) diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index e3f4072..b01d844 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -1380,6 +1380,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 +1479,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 +1566,15 @@ function registerWindowIpc() { } }); + ipcMain.handle("app:isDebugEnabled", () => { + try { + return debugEnabled(); + } catch (err) { + logMain(`[main] app:isDebugEnabled failed: ${err?.message || err}`); + return false; + } + }); + ipcMain.handle("app:setCloseBehavior", (_event, behavior) => { try { const next = setCloseBehavior(behavior); @@ -1727,6 +1800,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). diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 25f7352..501d0e3 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld("wechatDesktop", { toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), close: () => ipcRenderer.invoke("window:close"), isMaximized: () => ipcRenderer.invoke("window:isMaximized"), + isDebugEnabled: () => ipcRenderer.invoke("app:isDebugEnabled"), getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"), setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled), diff --git a/frontend/composables/chat/useChatMessages.js b/frontend/composables/chat/useChatMessages.js index a491578..7acb0fd 100644 --- a/frontend/composables/chat/useChatMessages.js +++ b/frontend/composables/chat/useChatMessages.js @@ -27,6 +27,22 @@ 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 + console.info(`[chat-messages] ${phase}`, { + account: String(selectedAccount.value || '').trim(), + selectedUsername: String(selectedContact.value?.username || '').trim(), + activeMessagesFor: String(activeMessagesFor.value || '').trim(), + ...details + }) + } const previewImageUrl = ref(null) const previewVideoUrl = ref(null) @@ -113,8 +129,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 +151,14 @@ export const useChatMessages = ({ timeDivider: formatTimeDivider(ts) } }) + if (shouldLogRender) { + lastRenderMessagesFingerprint = fingerprint + logMessagePhase('renderMessages:end', { + count: rendered.length, + reverseSides + }) + } + return rendered }) const updateJumpToBottomState = () => { @@ -333,6 +365,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 +393,47 @@ 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 + }) - 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 +450,10 @@ export const useChatMessages = ({ [username]: [...older, ...existing] } } + logMessagePhase('loadMessages:state-commit:end', { + username, + storedCount: (allMessages.value[username] || []).length + }) messagesMeta.value = { ...messagesMeta.value, @@ -388,8 +462,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 +486,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 + }) } } diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index c062109..3188acc 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -58,6 +58,64 @@ 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 + 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 +242,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 +329,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 +497,9 @@ const queueRealtimeSessionsRefresh = () => { } const onAccountChange = async () => { + logChatBootstrap('accountChange:start', { + selectedAccount: selectedAccount.value + }) try { isLoadingContacts.value = true contactsError.value = '' @@ -374,7 +511,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 +568,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 +583,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 +677,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' + }) } )