diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a0f52e9394..847f300b3b 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -53,6 +53,7 @@ import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { useTheme } from "@/composables/useTheme"; import { copyToClipboard } from "@/lib/clipboard"; +import { clearDebugLogs as clearStoredDebugLogs, downloadDebugLogs, getDebugLogBundleText } from "@/lib/debugLog"; import { aiListModels, aiTestConnection, @@ -115,6 +116,9 @@ const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confir const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon); const editIconTheme = ref(settingsStore.desktopSettings.icon_theme); +const editDebugLoggingEnabled = ref(settingsStore.desktopSettings.debug_logging_enabled); +const debugLogCopied = ref(false); +const debugLogDownloaded = ref(false); const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader); const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader); const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions); @@ -273,6 +277,7 @@ watch( editAppLayout.value = settingsStore.editorSettings.appLayout; editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon; editIconTheme.value = settingsStore.desktopSettings.icon_theme; + editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled; editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader; editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader; editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions; @@ -321,6 +326,7 @@ function hasChanges(): boolean { editAppLayout.value !== settingsStore.editorSettings.appLayout || editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon || editIconTheme.value !== settingsStore.desktopSettings.icon_theme || + editDebugLoggingEnabled.value !== settingsStore.desktopSettings.debug_logging_enabled || editShowColumnCommentsInHeader.value !== settingsStore.editorSettings.showColumnCommentsInHeader || editShowColumnTypesInHeader.value !== settingsStore.editorSettings.showColumnTypesInHeader || editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions || @@ -376,6 +382,7 @@ async function persistSettings() { await settingsStore.updateDesktopSettings({ show_tray_icon: editShowTrayIcon.value, icon_theme: editIconTheme.value, + debug_logging_enabled: editDebugLoggingEnabled.value, }); if (sidebarObjectDisplayChanged) { await connectionStore.refreshAllTree(); @@ -404,6 +411,7 @@ function resetDefaults() { editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon; editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme; + editDebugLoggingEnabled.value = DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled; editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader; editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader; editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions; @@ -629,6 +637,29 @@ function openExternalUrl(url: string) { } } +async function copyDebugLogs() { + await copyToClipboard(await getDebugLogBundleText()); + debugLogCopied.value = true; + window.setTimeout(() => { + debugLogCopied.value = false; + }, 1500); +} + +function clearDebugLogs() { + clearStoredDebugLogs(); + debugLogCopied.value = false; + debugLogDownloaded.value = false; +} + +async function exportDebugLogs() { + const saved = await downloadDebugLogs(); + if (!saved) return; + debugLogDownloaded.value = true; + window.setTimeout(() => { + debugLogDownloaded.value = false; + }, 1500); +} + // ---------- MCP Server ---------- const mcpStatus = ref(null); const mcpStatusLoading = ref(false); @@ -871,6 +902,7 @@ watch( await settingsStore.initDesktopSettings(); editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon; editIconTheme.value = settingsStore.desktopSettings.icon_theme; + editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled; webdavPassword.value = ""; await refreshWebDavPasswordStatus(); syncAiEditState(); @@ -1559,6 +1591,29 @@ watch( +
+
+
+ +

+ {{ t("settings.debugLoggingEnabledDescription") }} +

+
+ +
+
+ + + +
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2f357aa67a..b5534e992c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1673,6 +1673,14 @@ export default { updateNotificationsEnabled: "Enable update reminders", updateNotificationsEnabledDescription: "When disabled, DBX will not automatically check app or driver updates or show update badges. Manual checks are still available.", + debugLoggingEnabled: "Enable debug logs", + debugLoggingEnabledDescription: + "Record more detailed local user-side diagnostic logs while troubleshooting reports. Disabled by default.", + debugLogsCopy: "Copy logs", + debugLogsCopied: "Copied", + debugLogsDownload: "Download logs", + debugLogsDownloaded: "Downloaded", + debugLogsClear: "Clear logs", dataGridDisplay: "Data grid display", showColumnCommentsInHeader: "Show column comments under names", showColumnCommentsInHeaderDescription: "Display table column comments directly below grid column names.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4c28fc43f0..2ef5bb979a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1526,6 +1526,14 @@ export default { updateNotificationsEnabled: "Activar recordatorios de actualización", updateNotificationsEnabledDescription: "Al desactivarlo, DBX no comprobará automáticamente actualizaciones de la app ni de los controladores, ni mostrará indicadores. Las comprobaciones manuales siguen disponibles.", + debugLoggingEnabled: "Activar logs de depuración", + debugLoggingEnabledDescription: + "Registra logs de diagnóstico locales del lado del usuario al investigar reportes. Desactivado por defecto.", + debugLogsCopy: "Copiar logs", + debugLogsCopied: "Copiado", + debugLogsDownload: "Descargar logs", + debugLogsDownloaded: "Descargado", + debugLogsClear: "Borrar logs", dataGridDisplay: "Visualización de la tabla", showColumnCommentsInHeader: "Mostrar comentarios bajo los nombres", showColumnCommentsInHeaderDescription: diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 8a17a4acaf..8589a27708 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1669,6 +1669,14 @@ export default { updateNotificationsEnabled: "Abilita promemoria aggiornamenti", updateNotificationsEnabledDescription: "Se disattivato, DBX non controllerà automaticamente gli aggiornamenti dell'app o dei driver e non mostrerà badge. I controlli manuali restano disponibili.", + debugLoggingEnabled: "Abilita log di debug", + debugLoggingEnabledDescription: + "Registra log diagnostici locali lato utente durante l'analisi delle segnalazioni. Disattivato per impostazione predefinita.", + debugLogsCopy: "Copia log", + debugLogsCopied: "Copiati", + debugLogsDownload: "Scarica log", + debugLogsDownloaded: "Scaricati", + debugLogsClear: "Cancella log", dataGridDisplay: "Visualizzazione griglia dati", showColumnCommentsInHeader: "Mostra i commenti delle colonne sotto i nomi", showColumnCommentsInHeaderDescription: diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ecb0c46da7..4c89e2590d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1659,6 +1659,14 @@ export default { updateNotificationsEnabled: "Ativar lembretes de atualização", updateNotificationsEnabledDescription: "Quando desativado, o DBX não verificará automaticamente atualizações do app ou dos drivers nem mostrará indicadores. Verificações manuais continuam disponíveis.", + debugLoggingEnabled: "Ativar logs de depuração", + debugLoggingEnabledDescription: + "Registra logs locais de diagnóstico do lado do usuário ao investigar relatos. Desativado por padrão.", + debugLogsCopy: "Copiar logs", + debugLogsCopied: "Copiado", + debugLogsDownload: "Baixar logs", + debugLogsDownloaded: "Baixado", + debugLogsClear: "Limpar logs", dataGridDisplay: "Exibição da grade de dados", showColumnCommentsInHeader: "Mostrar comentários de coluna sob os nomes", showColumnCommentsInHeaderDescription: diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 66ae64d54e..2384227415 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1642,6 +1642,13 @@ export default { updateNotificationsEnabled: "启用更新提醒", updateNotificationsEnabledDescription: "关闭后,DBX 不会自动检查应用和驱动更新,也不会显示更新红点;仍可手动检查更新。", + debugLoggingEnabled: "启用调试日志", + debugLoggingEnabledDescription: "开启后在本机记录更详细的用户侧诊断日志,反馈问题时可临时开启;默认关闭。", + debugLogsCopy: "复制日志", + debugLogsCopied: "已复制", + debugLogsDownload: "下载日志", + debugLogsDownloaded: "已下载", + debugLogsClear: "清空日志", dataGridDisplay: "数据表格显示", showColumnCommentsInHeader: "在字段名下方显示注释", showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2fc8e26de7..f917708ffb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1583,6 +1583,13 @@ export default { updateNotificationsEnabled: "啟用更新提醒", updateNotificationsEnabledDescription: "關閉後,DBX 不會自動檢查應用程式和驅動程式更新,也不會顯示更新紅點;仍可手動檢查更新。", + debugLoggingEnabled: "啟用偵錯日誌", + debugLoggingEnabledDescription: "開啟後在本機記錄更詳細的使用者側診斷日誌,回報問題時可暫時開啟;預設關閉。", + debugLogsCopy: "複製日誌", + debugLogsCopied: "已複製", + debugLogsDownload: "下載日誌", + debugLogsDownloaded: "已下載", + debugLogsClear: "清空日誌", dataGridDisplay: "資料表格顯示", showColumnCommentsInHeader: "在欄位名稱下方顯示註解", showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index c50610c6e3..1bd9dc3784 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1,5 +1,6 @@ import { isTauriRuntime } from "./tauriRuntime"; import type * as TauriModule from "./tauri"; +import { appendDebugLog } from "./debugLog"; // --------------------------------------------------------------------------- // Lazy backend resolution (avoids top-level await) @@ -21,8 +22,25 @@ async function getBackend(): Promise { function forward(name: K): Backend[K] { return (async (...args: unknown[]) => { + const startedAt = performance.now(); + const operation = String(name); + appendDebugLog("debug", "[DBX][api:start]", operation); const b = await getBackend(); - return (b[name] as (...a: unknown[]) => unknown)(...args); + try { + const result = await (b[name] as (...a: unknown[]) => unknown)(...args); + appendDebugLog("debug", "[DBX][api:success]", { + operation, + elapsedMs: Math.round(performance.now() - startedAt), + }); + return result; + } catch (error) { + appendDebugLog("error", "[DBX][api:error]", { + operation, + elapsedMs: Math.round(performance.now() - startedAt), + error, + }); + throw error; + } }) as unknown as Backend[K]; } diff --git a/apps/desktop/src/lib/debugLog.ts b/apps/desktop/src/lib/debugLog.ts new file mode 100644 index 0000000000..dbfe6074c3 --- /dev/null +++ b/apps/desktop/src/lib/debugLog.ts @@ -0,0 +1,296 @@ +const DEBUG_LOG_ENABLED_KEY = "dbx-debug-logging-enabled"; +const DEBUG_LOG_ENTRIES_KEY = "dbx-debug-log-entries"; +const MAX_DEBUG_LOG_ENTRIES = 1500; +const MAX_TEXT_LENGTH = 4000; +const MAX_LABEL_LENGTH = 120; + +type DebugLogLevel = "debug" | "info" | "log" | "warn" | "error"; + +interface DebugLogEntry { + timestamp: string; + level: DebugLogLevel; + message: string; +} + +let installed = false; +let originalConsole: Partial void>> = {}; + +function safeLocalStorageGet(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function safeLocalStorageSet(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + // Ignore quota or unavailable storage errors. The app should keep running. + } +} + +function safeLocalStorageRemove(key: string) { + try { + localStorage.removeItem(key); + } catch {} +} + +export function isDebugLoggingEnabled(): boolean { + return safeLocalStorageGet(DEBUG_LOG_ENABLED_KEY) === "1"; +} + +function readEntries(): DebugLogEntry[] { + const raw = safeLocalStorageGet(DEBUG_LOG_ENTRIES_KEY); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.slice(-MAX_DEBUG_LOG_ENTRIES) : []; + } catch { + return []; + } +} + +function redactSensitiveText(value: string): string { + return value + .replace( + /("(?:password|passphrase|apiKey|api_key|token|secret|connectionString|connection_string)"\s*:\s*")([^"]*)(")/gi, + "$1[redacted]$3", + ) + .replace(/\b(password|passphrase|apiKey|api_key|token|secret)=([^&\s]+)/gi, "$1=[redacted]"); +} + +function formatValue(value: unknown, seen = new WeakSet()): string { + if (value instanceof Error) { + return redactSensitiveText([value.name, value.message, value.stack].filter(Boolean).join(": ")); + } + if (typeof value === "string") return redactSensitiveText(value); + if (typeof value !== "object" || value === null) return String(value); + if (seen.has(value)) return "[Circular]"; + seen.add(value); + try { + return redactSensitiveText( + JSON.stringify(value, (_key, item) => { + if (typeof item === "function") return `[Function ${item.name || "anonymous"}]`; + return item; + }), + ); + } catch { + return redactSensitiveText(String(value)); + } +} + +function formatArgs(args: unknown[]): string { + const message = args.map((arg) => formatValue(arg)).join(" "); + return message.length > MAX_TEXT_LENGTH ? `${message.slice(0, MAX_TEXT_LENGTH)}...` : message; +} + +export function appendDebugLog(level: DebugLogLevel, ...args: unknown[]) { + if (!isDebugLoggingEnabled()) return; + const entries = readEntries(); + entries.push({ + timestamp: new Date().toISOString(), + level, + message: formatArgs(args), + }); + safeLocalStorageSet(DEBUG_LOG_ENTRIES_KEY, JSON.stringify(entries.slice(-MAX_DEBUG_LOG_ENTRIES))); +} + +export function setDebugLoggingEnabled(enabled: boolean) { + safeLocalStorageSet(DEBUG_LOG_ENABLED_KEY, enabled ? "1" : "0"); + if (enabled) { + appendDebugLog("info", "[DBX][debug-log] enabled", { + url: location.href, + viewport: `${window.innerWidth}x${window.innerHeight}`, + devicePixelRatio: window.devicePixelRatio, + language: navigator.language, + online: navigator.onLine, + }); + } +} + +export function clearDebugLogs() { + safeLocalStorageRemove(DEBUG_LOG_ENTRIES_KEY); +} + +export function getDebugLogText(): string { + const entries = readEntries(); + const header = [ + `DBX debug log`, + `Exported: ${new Date().toISOString()}`, + `User agent: ${navigator.userAgent}`, + `Platform: ${navigator.platform}`, + `Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown"}`, + "", + ]; + const body = entries.map((entry) => `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}`); + return [...header, ...body].join("\n"); +} + +export async function downloadDebugLogs() { + const text = await getDebugLogBundleText(); + const filename = `dbx-debug-log-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; + if (typeof window !== "undefined" && isTauriRuntimeLike()) { + const [{ save }, { writeTextFile }] = await Promise.all([ + import("@tauri-apps/plugin-dialog"), + import("@tauri-apps/plugin-fs"), + ]); + const path = await save({ + defaultPath: filename, + filters: [{ name: "Text", extensions: ["txt", "log"] }], + }); + if (!path) return false; + await writeTextFile(path, text); + return true; + } + + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + return true; +} + +export async function getDebugLogBundleText(): Promise { + const sections = [getDebugLogText()]; + const nativeLogs = await loadNativeDebugLogs().catch((error) => `Failed to load native logs: ${formatValue(error)}`); + if (nativeLogs) { + sections.push(["", "===== Native / Tauri logs =====", nativeLogs].join("\n")); + } + return sections.join("\n"); +} + +async function loadNativeDebugLogs(): Promise { + if (!isTauriRuntimeLike()) return ""; + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("load_native_debug_logs"); +} + +function isTauriRuntimeLike(): boolean { + return "__TAURI_INTERNALS__" in globalThis || "__TAURI__" in globalThis; +} + +export function installDebugLogCapture() { + if (installed) return; + installed = true; + originalConsole = { + debug: console.debug.bind(console), + info: console.info.bind(console), + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + (Object.keys(originalConsole) as DebugLogLevel[]).forEach((level) => { + console[level] = (...args: unknown[]) => { + appendDebugLog(level, ...args); + originalConsole[level]?.(...args); + }; + }); + window.addEventListener("error", (event) => { + const target = event.target; + if (target instanceof HTMLElement) { + appendDebugLog("error", "[window:resource-error]", describeElement(target)); + return; + } + appendDebugLog("error", "[window:error]", event.error ?? event.message); + }); + window.addEventListener("unhandledrejection", (event) => { + appendDebugLog("error", "[window:unhandledrejection]", event.reason); + }); + window.addEventListener("online", () => appendDebugLog("info", "[window:online]")); + window.addEventListener("offline", () => appendDebugLog("warn", "[window:offline]")); + window.addEventListener("hashchange", () => appendDebugLog("info", "[window:hashchange]", location.hash)); + document.addEventListener("visibilitychange", () => { + appendDebugLog("info", "[document:visibilitychange]", document.visibilityState); + }); + document.addEventListener( + "click", + (event) => { + const target = event.target instanceof HTMLElement ? interactiveElement(event.target) : null; + if (!target) return; + appendDebugLog("debug", "[ui:click]", describeElement(target)); + }, + true, + ); + document.addEventListener( + "change", + (event) => { + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target || !isFormControl(target)) return; + appendDebugLog("debug", "[ui:change]", describeElement(target)); + }, + true, + ); + installPerformanceCapture(); +} + +function installPerformanceCapture() { + if (typeof PerformanceObserver === "undefined") return; + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.duration >= 250) { + appendDebugLog("warn", "[performance:long-task]", { + name: entry.name, + durationMs: Math.round(entry.duration), + startTimeMs: Math.round(entry.startTime), + }); + } + } + }); + observer.observe({ entryTypes: ["longtask"] }); + } catch { + // Long task entries are not available in every WebView. + } +} + +function interactiveElement(target: HTMLElement): HTMLElement | null { + return target.closest( + [ + "button", + "a[href]", + "input", + "select", + "textarea", + "[role='button']", + "[role='menuitem']", + "[role='tab']", + "[data-debug-id]", + ].join(","), + ); +} + +function isFormControl(target: HTMLElement): boolean { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + target.getAttribute("role") === "switch" + ); +} + +function describeElement(element: HTMLElement): Record { + const label = + element.getAttribute("data-debug-id") || + element.getAttribute("aria-label") || + element.getAttribute("title") || + element.textContent || + ""; + const attrs: Record = { + tag: element.tagName.toLowerCase(), + }; + if (element.id) attrs.id = element.id; + const role = element.getAttribute("role"); + if (role) attrs.role = role; + const type = element.getAttribute("type"); + if (type) attrs.type = type; + const safeLabel = redactSensitiveText(label.trim().replace(/\s+/g, " ")); + if (safeLabel) attrs.label = safeLabel.slice(0, MAX_LABEL_LENGTH); + const testId = element.getAttribute("data-testid"); + if (testId) attrs.testId = testId; + return attrs; +} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index a9d0e2b1e7..9e5af3d7b8 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -874,7 +874,7 @@ export async function loadAiConfig(): Promise { } export async function loadDesktopSettings(): Promise { - return { show_tray_icon: true, icon_theme: "default" }; + return { show_tray_icon: true, icon_theme: "default", debug_logging_enabled: false }; } export async function saveDesktopSettings(_settings: DesktopSettings): Promise { diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index e19e87e700..9f53305bf6 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -145,6 +145,7 @@ export interface DriverRuntimeSummary { export interface DesktopSettings { show_tray_icon: boolean; icon_theme: "default" | "black"; + debug_logging_enabled: boolean; } export interface WebDavConfig { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 629d23e2ff..87450b2b85 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from "pinia"; import VueVirtualScroller from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; import "./styles/globals.css"; +import { installDebugLogCapture } from "@/lib/debugLog"; function startupErrorMessage(error: unknown): string { if (error instanceof Error) { @@ -89,5 +90,6 @@ async function bootstrap() { console.log("[STARTUP] vue mounted"); } +installDebugLogCapture(); installStartupErrorHandlers(); void bootstrap().catch(renderStartupError); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 5766b7f449..cf883ffc4d 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -13,6 +13,7 @@ import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebarTableNameDispl import type { SidebarActivation } from "@/lib/treeNodeClick"; import type { SqlSnippet } from "@/types/database"; import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion"; +import { setDebugLoggingEnabled } from "@/lib/debugLog"; export type AiProvider = | "claude" @@ -39,6 +40,7 @@ export interface AiConfig { export interface DesktopSettings { show_tray_icon: boolean; icon_theme: DesktopIconTheme; + debug_logging_enabled: boolean; } export type DesktopIconTheme = "default" | "black"; @@ -46,6 +48,7 @@ export type DesktopIconTheme = "default" | "black"; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { show_tray_icon: true, icon_theme: "default", + debug_logging_enabled: false, }; function normalizeDesktopSettings(settings: Partial | null | undefined): DesktopSettings { @@ -53,6 +56,7 @@ function normalizeDesktopSettings(settings: Partial | null | un return { show_tray_icon: settings?.show_tray_icon ?? DEFAULT_DESKTOP_SETTINGS.show_tray_icon, icon_theme: iconTheme, + debug_logging_enabled: settings?.debug_logging_enabled ?? DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled, }; } @@ -567,6 +571,7 @@ export const useSettingsStore = defineStore("settings", () => { async function initDesktopSettings() { if (isDesktopSettingsLoaded.value) return; desktopSettings.value = normalizeDesktopSettings(await api.loadDesktopSettings().catch(() => null)); + setDebugLoggingEnabled(desktopSettings.value.debug_logging_enabled); isDesktopSettingsLoaded.value = true; } @@ -577,10 +582,12 @@ export const useSettingsStore = defineStore("settings", () => { ...partial, }; desktopSettings.value = normalizeDesktopSettings(next); + setDebugLoggingEnabled(desktopSettings.value.debug_logging_enabled); try { await api.saveDesktopSettings(desktopSettings.value); } catch (error) { desktopSettings.value = previous; + setDebugLoggingEnabled(previous.debug_logging_enabled); throw error; } } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 8f8eee83d6..a5efd88730 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -32,11 +32,13 @@ pub struct TabRuntimeCacheEntry { pub struct DesktopSettings { pub show_tray_icon: bool, pub icon_theme: DesktopIconTheme, + #[serde(default)] + pub debug_logging_enabled: bool, } impl Default for DesktopSettings { fn default() -> Self { - Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default } + Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default, debug_logging_enabled: false } } } @@ -430,6 +432,10 @@ impl Storage { "icon_theme".to_string(), serde_json::to_value(desktop_settings.icon_theme).map_err(|e| e.to_string())?, ); + settings.insert( + "debug_logging_enabled".to_string(), + serde_json::Value::Bool(desktop_settings.debug_logging_enabled), + ); self.save_app_settings_json(&settings).await } @@ -442,6 +448,10 @@ impl Storage { .or_else(|| settings.get("run_in_background").and_then(|value| value.as_bool())) .unwrap_or_else(|| DesktopSettings::default().show_tray_icon), icon_theme: DesktopIconTheme::from_settings_value(settings.get("icon_theme")), + debug_logging_enabled: settings + .get("debug_logging_enabled") + .and_then(|value| value.as_bool()) + .unwrap_or_else(|| DesktopSettings::default().debug_logging_enabled), }) } @@ -1334,14 +1344,18 @@ mod tests { storage.save_password_hash("hash-1").await.unwrap(); storage - .save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }) + .save_desktop_settings(&DesktopSettings { + show_tray_icon: false, + icon_theme: DesktopIconTheme::Black, + debug_logging_enabled: true, + }) .await .unwrap(); assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-1".to_string())); assert_eq!( storage.load_desktop_settings().await.unwrap(), - DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black } + DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, debug_logging_enabled: true } ); } @@ -1365,6 +1379,7 @@ mod tests { assert_eq!(settings.get("run_in_background"), None); assert_eq!(settings.get("show_tray_icon").and_then(|value| value.as_bool()), Some(true)); assert_eq!(settings.get("icon_theme").and_then(|value| value.as_str()), Some("black")); + assert_eq!(settings.get("debug_logging_enabled").and_then(|value| value.as_bool()), Some(false)); } #[tokio::test] @@ -1373,7 +1388,11 @@ mod tests { let storage = Storage::open(&path).await.unwrap(); storage - .save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }) + .save_desktop_settings(&DesktopSettings { + show_tray_icon: false, + icon_theme: DesktopIconTheme::Black, + ..DesktopSettings::default() + }) .await .unwrap(); storage.save_password_hash("hash-2").await.unwrap(); @@ -1381,7 +1400,11 @@ mod tests { assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-2".to_string())); assert_eq!( storage.load_desktop_settings().await.unwrap(), - DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black } + DesktopSettings { + show_tray_icon: false, + icon_theme: DesktopIconTheme::Black, + ..DesktopSettings::default() + } ); } diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index 3a4144009e..d1b0e63b15 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -1,10 +1,10 @@ use std::sync::Arc; use dbx_core::storage::DesktopSettings; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use super::connection::AppState; -use crate::apply_desktop_settings; +use crate::{apply_debug_log_level, apply_desktop_settings}; #[tauri::command] pub async fn load_desktop_settings(state: State<'_, Arc>) -> Result { @@ -18,6 +18,7 @@ pub async fn save_desktop_settings( settings: DesktopSettings, ) -> Result<(), String> { state.storage.save_desktop_settings(&settings).await?; + apply_debug_log_level(settings.debug_logging_enabled); if let Err(err) = apply_desktop_settings(&app, &settings) { eprintln!("Failed to apply desktop settings: {err}"); } @@ -33,3 +34,43 @@ pub async fn load_pinned_tree_node_ids(state: State<'_, Arc>) -> Resul pub async fn save_pinned_tree_node_ids(state: State<'_, Arc>, ids: Vec) -> Result<(), String> { state.storage.save_pinned_tree_node_ids(&ids).await } + +#[tauri::command] +pub async fn load_native_debug_logs(app: AppHandle) -> Result { + const MAX_FILES: usize = 6; + const MAX_FILE_BYTES: u64 = 512 * 1024; + let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?; + if !log_dir.exists() { + return Ok(format!("Native log dir does not exist yet: {}", log_dir.display())); + } + let mut files = std::fs::read_dir(&log_dir) + .map_err(|e| e.to_string())? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let path = entry.path(); + let metadata = entry.metadata().ok()?; + if !metadata.is_file() { + return None; + } + let modified = metadata.modified().ok()?; + Some((path, modified, metadata.len())) + }) + .collect::>(); + files.sort_by_key(|(_, modified, _)| *modified); + files.reverse(); + + let mut output = String::new(); + output.push_str(&format!("Native log dir: {}\n", log_dir.display())); + for (path, _, len) in files.into_iter().take(MAX_FILES) { + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("unknown"); + output.push_str(&format!("\n===== {name} =====\n")); + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + let start = if len > MAX_FILE_BYTES { bytes.len().saturating_sub(MAX_FILE_BYTES as usize) } else { 0 }; + if start > 0 { + output.push_str("[truncated to last 512 KiB]\n"); + } + output.push_str(&String::from_utf8_lossy(&bytes[start..])); + output.push('\n'); + } + Ok(output) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88d08f5697..d6b8c291ac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,10 @@ const DESKTOP_TRAY_ID: &str = "main-tray"; const MACOS_TRAY_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/tray-macos-template.png"); const BLACK_APP_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/icon-black.png"); +pub(crate) fn apply_debug_log_level(debug_logging_enabled: bool) { + log::set_max_level(if debug_logging_enabled { log::LevelFilter::Debug } else { log::LevelFilter::Off }); +} + fn should_hide_window_on_close(target_os: &str) -> bool { matches!(target_os, "macos" | "windows") } @@ -153,6 +157,7 @@ fn apply_desktop_tray_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIcon } pub(crate) fn apply_desktop_settings(app: &tauri::AppHandle, desktop_settings: &DesktopSettings) -> tauri::Result<()> { + apply_debug_log_level(desktop_settings.debug_logging_enabled); apply_desktop_icon_theme(app, desktop_settings.icon_theme)?; if matches!(std::env::consts::OS, "macos" | "windows") { if let Some(tray) = app.tray_by_id(DESKTOP_TRAY_ID) { @@ -266,10 +271,6 @@ pub fn run() { let setup_start = Instant::now(); eprintln!("[STARTUP] plugins registered in {:?}", startup_begin.elapsed()); - if cfg!(debug_assertions) { - app.handle().plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build())?; - } - let default_data_dir = app.path().app_data_dir().map_err(|e| e.to_string()).expect("Failed to resolve app data dir"); let data_dir = data_dir::resolve_data_dir(default_data_dir); @@ -286,6 +287,8 @@ pub fn run() { s }); let desktop_settings = tauri::async_runtime::block_on(storage.load_desktop_settings()).unwrap_or_default(); + app.handle().plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Debug).build())?; + apply_debug_log_level(desktop_settings.debug_logging_enabled); eprintln!("[STARTUP] storage ready in {:?}", t.elapsed()); let state = if data_dir::uses_custom_data_dir() { @@ -355,6 +358,7 @@ pub fn run() { commands::app_settings::save_desktop_settings, commands::app_settings::load_pinned_tree_node_ids, commands::app_settings::save_pinned_tree_node_ids, + commands::app_settings::load_native_debug_logs, commands::cloud_sync::webdav_sync_test, commands::cloud_sync::webdav_password_status, commands::cloud_sync::save_webdav_saved_password,