diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 8a0e71a8c..bcbcf6156 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -57,6 +57,7 @@ import { installMcpServer, forgetWebdavSyncSecretsPassphrase, forgetWebdavSavedPassword, + getAppSupportInfo, listSystemFonts, saveWebdavSyncSecretsPreference, saveWebdavSavedPassword, @@ -65,6 +66,7 @@ import { webdavSyncSecretsStatus, webdavSyncTest, webdavSyncUpload, + type AppSupportInfo, type AiModelInfo, type McpServerStatus, type WebDavConfig, @@ -96,6 +98,7 @@ import { LOCALE_OPTIONS } from "@/lib/app/localeOptions"; import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, writeWebDavAutoUploadFields } from "@/lib/webdav/webdavAutoUploadConfig"; import { apiUrl } from "@/lib/common/webPath"; import { DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY } from "@/lib/app/appFonts"; +import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, type AppSupportInfoLabels } from "@/lib/app/supportInfo"; const { t } = useI18n(); const { toast } = useToast(); @@ -1195,6 +1198,20 @@ function setSidebarActivation(value: "single" | "double") { const activeSettingsTab = ref("appearance"); const isWeb = !isTauriRuntime(); const displayedAppVersion = computed(() => (props.appVersion ? `v${props.appVersion}` : "")); +const appSupportInfo = ref(null); +const appSupportInfoLoading = ref(false); +const appSupportInfoError = ref(""); +const appSupportInfoCopied = ref(false); +const appSupportInfoLabels = computed(() => ({ + appVersion: t("settings.supportInfoAppVersion"), + runtime: t("settings.supportInfoRuntime"), + runtimeDesktop: t("settings.supportInfoRuntimeDesktop"), + runtimeWeb: t("settings.supportInfoRuntimeWeb"), + operatingSystem: t("settings.supportInfoOperatingSystem"), + architecture: t("settings.supportInfoArchitecture"), + unknown: t("settings.supportInfoUnknown"), +})); +const appSupportInfoRows = computed(() => (appSupportInfo.value ? buildAppSupportInfoRows(appSupportInfo.value, appSupportInfoLabels.value) : [])); type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about"; const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [ { value: "appearance", label: t("settings.appearanceTab") }, @@ -1236,6 +1253,44 @@ async function copyDebugLogs() { }, 1500); } +function fallbackAppSupportInfo(): AppSupportInfo { + return { + appVersion: props.appVersion || "", + runtime: isWeb ? "web" : "desktop", + osName: "", + osVersion: null, + arch: "", + }; +} + +async function refreshAppSupportInfo() { + if (appSupportInfoLoading.value) return; + appSupportInfoLoading.value = true; + appSupportInfoError.value = ""; + try { + appSupportInfo.value = await getAppSupportInfo(); + } catch (e: any) { + appSupportInfo.value = appSupportInfo.value || fallbackAppSupportInfo(); + appSupportInfoError.value = e?.message || String(e); + } finally { + appSupportInfoLoading.value = false; + } +} + +async function copyAppSupportInfo() { + if (!appSupportInfo.value) await refreshAppSupportInfo(); + if (!appSupportInfo.value) return; + try { + await copyToClipboard(formatAppSupportInfoForClipboard(appSupportInfo.value, appSupportInfoLabels.value)); + appSupportInfoCopied.value = true; + window.setTimeout(() => { + appSupportInfoCopied.value = false; + }, 1500); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } +} + function clearDebugLogs() { clearStoredDebugLogs(); debugLogCopied.value = false; @@ -1572,6 +1627,7 @@ watch( syncAiEditState(); if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus(); if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus(); + if (activeSettingsTab.value === "about") void refreshAppSupportInfo(); await scrollToInitialSettingsSection(); } }, @@ -1608,6 +1664,7 @@ watch([webdavAutoUploadEnabled, webdavAutoUploadIntervalMinutes], () => { watch(activeSettingsTab, (tab) => { if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus(); if (tab === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus(); + if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo(); if (tab === "appearance") { checkLayoutDescTruncation(); checkIconThemeDescTruncation(); @@ -4144,6 +4201,29 @@ onUnmounted(cleanupPreviewEditor); +
+
+
+ +

{{ t("settings.supportInfoDescription") }}

+
+ +
+
+
+
{{ row.label }}
+
{{ row.value }}
+
+
+

{{ t("settings.supportInfoLoading") }}

+

{{ t("settings.supportInfoLoadFailed", { message: appSupportInfoError }) }}

+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5fb66ccb1..f29b8a9c2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3254,6 +3254,19 @@ export default { mcpRefresh: "Check again", mcpGuide: "MCP guide", aboutDescription: "An open-source, lightweight database management tool.", + supportInfoTitle: "Support information", + supportInfoDescription: "Copy these environment details when filing issues or asking for help.", + supportInfoCopy: "Copy support info", + supportInfoCopied: "Copied", + supportInfoLoading: "Loading support information...", + supportInfoLoadFailed: "Failed to load full support information: {message}", + supportInfoAppVersion: "DBX Version", + supportInfoRuntime: "Runtime", + supportInfoRuntimeDesktop: "Desktop", + supportInfoRuntimeWeb: "Web", + supportInfoOperatingSystem: "Operating System", + supportInfoArchitecture: "Architecture", + supportInfoUnknown: "Unknown", community: "Community", qqGroup: "QQ Group", wechatGroup: "WeChat Group", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index bb538f76e..87321aa46 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3155,6 +3155,19 @@ export default withEnglishFallback({ mcpRefresh: "Comprobar de nuevo", mcpGuide: "Guía MCP", aboutDescription: "Una herramienta de administración de bases de datos liviana y de código abierto.", + supportInfoTitle: "Información de soporte", + supportInfoDescription: "Copia estos datos del entorno al crear issues o pedir ayuda.", + supportInfoCopy: "Copiar información", + supportInfoCopied: "Copiado", + supportInfoLoading: "Cargando información de soporte...", + supportInfoLoadFailed: "No se pudo cargar toda la información de soporte: {message}", + supportInfoAppVersion: "Versión de DBX", + supportInfoRuntime: "Entorno", + supportInfoRuntimeDesktop: "Escritorio", + supportInfoRuntimeWeb: "Web", + supportInfoOperatingSystem: "Sistema operativo", + supportInfoArchitecture: "Arquitectura", + supportInfoUnknown: "Desconocido", community: "Comunidad", qqGroup: "Grupo QQ", wechatGroup: "Grupo WeChat", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 03ca00844..3600cbd6a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3153,6 +3153,19 @@ export default withEnglishFallback({ mcpRefresh: "Verifica di nuovo", mcpGuide: "Guida MCP", aboutDescription: "Uno strumento di gestione di database leggero e open-source.", + supportInfoTitle: "Informazioni di supporto", + supportInfoDescription: "Copia questi dettagli dell'ambiente quando apri issue o chiedi aiuto.", + supportInfoCopy: "Copia informazioni", + supportInfoCopied: "Copiato", + supportInfoLoading: "Caricamento informazioni di supporto...", + supportInfoLoadFailed: "Impossibile caricare tutte le informazioni di supporto: {message}", + supportInfoAppVersion: "Versione DBX", + supportInfoRuntime: "Ambiente", + supportInfoRuntimeDesktop: "Desktop", + supportInfoRuntimeWeb: "Web", + supportInfoOperatingSystem: "Sistema operativo", + supportInfoArchitecture: "Architettura", + supportInfoUnknown: "Sconosciuto", community: "Community", qqGroup: "Gruppo QQ", wechatGroup: "Gruppo WeChat", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d0f397885..957f8a00d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3153,6 +3153,19 @@ export default withEnglishFallback({ queryExportKeysetOptimizationEnabled: "キーセット最適化を試行", queryExportKeysetOptimizationEnabledDescription: "安全に認識された単一テーブルクエリにのみ適用。複雑なクエリは自動的にフォールバックします。", aboutDescription: "オープンソースの軽量データベース管理ツール。", + supportInfoTitle: "サポート情報", + supportInfoDescription: "Issueの作成や問い合わせ時に、この環境情報をコピーできます。", + supportInfoCopy: "サポート情報をコピー", + supportInfoCopied: "コピーしました", + supportInfoLoading: "サポート情報を読み込み中...", + supportInfoLoadFailed: "完全なサポート情報の読み込みに失敗しました: {message}", + supportInfoAppVersion: "DBXバージョン", + supportInfoRuntime: "実行環境", + supportInfoRuntimeDesktop: "デスクトップ", + supportInfoRuntimeWeb: "Web", + supportInfoOperatingSystem: "オペレーティングシステム", + supportInfoArchitecture: "アーキテクチャ", + supportInfoUnknown: "不明", community: "コミュニティ", qqGroup: "QQグループ", wechatGroup: "WeChatグループ", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 862805c63..55cfaee8f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3154,6 +3154,19 @@ export default withEnglishFallback({ mcpRefresh: "Verificar novamente", mcpGuide: "Guia do MCP", aboutDescription: "Uma ferramenta de gerenciamento de banco de dados leve e de código aberto.", + supportInfoTitle: "Informações de suporte", + supportInfoDescription: "Copie estes detalhes do ambiente ao abrir issues ou pedir ajuda.", + supportInfoCopy: "Copiar informações", + supportInfoCopied: "Copiado", + supportInfoLoading: "Carregando informações de suporte...", + supportInfoLoadFailed: "Falha ao carregar todas as informações de suporte: {message}", + supportInfoAppVersion: "Versão do DBX", + supportInfoRuntime: "Ambiente", + supportInfoRuntimeDesktop: "Desktop", + supportInfoRuntimeWeb: "Web", + supportInfoOperatingSystem: "Sistema operacional", + supportInfoArchitecture: "Arquitetura", + supportInfoUnknown: "Desconhecido", community: "Comunidade", qqGroup: "Grupo do QQ", wechatGroup: "Grupo do WeChat", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 22caf45a4..a664d11c0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3257,6 +3257,19 @@ export default withEnglishFallback({ mcpRefresh: "重新检查", mcpGuide: "MCP 指南", aboutDescription: "开源、轻量的数据库管理工具。", + supportInfoTitle: "支持信息", + supportInfoDescription: "提交 issue 或寻求帮助时,可复制这些环境信息。", + supportInfoCopy: "复制支持信息", + supportInfoCopied: "已复制", + supportInfoLoading: "正在加载支持信息...", + supportInfoLoadFailed: "完整支持信息加载失败:{message}", + supportInfoAppVersion: "DBX 版本", + supportInfoRuntime: "运行环境", + supportInfoRuntimeDesktop: "桌面端", + supportInfoRuntimeWeb: "Web 端", + supportInfoOperatingSystem: "操作系统", + supportInfoArchitecture: "系统架构", + supportInfoUnknown: "未知", community: "社区", qqGroup: "QQ 群", wechatGroup: "微信交流群", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1f50be10b..398460c72 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2955,6 +2955,19 @@ export default withEnglishFallback({ jdbcDeleteSuccess: "驅動程式已刪除", jdbcNoDrivers: "還沒有匯入 JDBC 驅動程式。", aboutDescription: "開源、輕量的資料庫管理工具。", + supportInfoTitle: "支援資訊", + supportInfoDescription: "提交 issue 或尋求協助時,可複製這些環境資訊。", + supportInfoCopy: "複製支援資訊", + supportInfoCopied: "已複製", + supportInfoLoading: "正在載入支援資訊...", + supportInfoLoadFailed: "完整支援資訊載入失敗:{message}", + supportInfoAppVersion: "DBX 版本", + supportInfoRuntime: "執行環境", + supportInfoRuntimeDesktop: "桌面端", + supportInfoRuntimeWeb: "Web 端", + supportInfoOperatingSystem: "作業系統", + supportInfoArchitecture: "系統架構", + supportInfoUnknown: "未知", community: "社群", qqGroup: "QQ 群", wechatGroup: "微信交流群", diff --git a/apps/desktop/src/lib/app/supportInfo.ts b/apps/desktop/src/lib/app/supportInfo.ts new file mode 100644 index 000000000..f187ebc70 --- /dev/null +++ b/apps/desktop/src/lib/app/supportInfo.ts @@ -0,0 +1,67 @@ +import type { AppSupportInfo } from "@/lib/backend/tauri"; + +export interface AppSupportInfoLabels { + appVersion: string; + runtime: string; + runtimeDesktop: string; + runtimeWeb: string; + operatingSystem: string; + architecture: string; + unknown: string; +} + +export interface AppSupportInfoRow { + key: keyof Pick; + label: string; + value: string; +} + +export function normalizeSupportInfoVersion(version: string | null | undefined, unknownLabel: string): string { + const trimmed = version?.trim(); + if (!trimmed) return unknownLabel; + return trimmed.startsWith("v") || trimmed.startsWith("V") ? `v${trimmed.slice(1)}` : `v${trimmed}`; +} + +export function formatSupportInfoRuntime(runtime: AppSupportInfo["runtime"], labels: AppSupportInfoLabels): string { + if (runtime === "desktop") return labels.runtimeDesktop; + if (runtime === "web") return labels.runtimeWeb; + return labels.unknown; +} + +export function formatSupportInfoOperatingSystem(info: AppSupportInfo, unknownLabel: string): string { + const name = info.osName?.trim(); + const version = info.osVersion?.trim(); + if (!name && !version) return unknownLabel; + return [name, version].filter(Boolean).join(" "); +} + +export function buildAppSupportInfoRows(info: AppSupportInfo, labels: AppSupportInfoLabels): AppSupportInfoRow[] { + return [ + { + key: "appVersion", + label: labels.appVersion, + value: normalizeSupportInfoVersion(info.appVersion, labels.unknown), + }, + { + key: "runtime", + label: labels.runtime, + value: formatSupportInfoRuntime(info.runtime, labels), + }, + { + key: "operatingSystem", + label: labels.operatingSystem, + value: formatSupportInfoOperatingSystem(info, labels.unknown), + }, + { + key: "architecture", + label: labels.architecture, + value: info.arch?.trim() || labels.unknown, + }, + ]; +} + +export function formatAppSupportInfoForClipboard(info: AppSupportInfo, labels: AppSupportInfoLabels): string { + return buildAppSupportInfoRows(info, labels) + .map((row) => `${row.label}: ${row.value}`) + .join("\n"); +} diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 5aa6d12c5..7cd521c87 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -440,6 +440,7 @@ export const checkForUpdates = forward("checkForUpdates"); export const getSystemProxyUrl = forward("getSystemProxyUrl"); export const downloadAndInstallUpdate = forward("downloadAndInstallUpdate"); export const getAppVersion = forward("getAppVersion"); +export const getAppSupportInfo = forward("getAppSupportInfo"); // Layout export const saveSidebarLayout = forward("saveSidebarLayout"); @@ -450,6 +451,7 @@ export const loadSidebarLayout = forward("loadSidebarLayout"); // --------------------------------------------------------------------------- export type { + AppSupportInfo, AiMessage, AiCompletionRequest, AiTaskContract, diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 4dab88e1c..39f35d075 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -97,6 +97,7 @@ import type { ExplainSqlBuildResult, DroppedFilePreviewSqlOptions, MongoGridFsFileInfo, + AppSupportInfo, } from "@/lib/backend/tauri"; import type { QueryEditability } from "@/lib/sql/sqlAnalysis"; import type { @@ -2102,6 +2103,17 @@ export async function getAppVersion(): Promise { return res.version; } +export async function getAppSupportInfo(): Promise { + const appVersion = await getAppVersion(); + return { + appVersion, + runtime: "web", + osName: navigator.platform || "web", + osVersion: null, + arch: "", + }; +} + // --------------------------------------------------------------------------- // Layout // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 5ab12ed2c..656968303 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -200,6 +200,14 @@ export interface WebDavSyncSecretsStatus { hasSavedPassphrase: boolean; } +export interface AppSupportInfo { + appVersion: string; + runtime: "desktop" | "web"; + osName: string; + osVersion?: string | null; + arch: string; +} + export interface QueryPagination { limit: number; offset: number; @@ -1301,6 +1309,10 @@ export async function getAppVersion(): Promise { return getVersion(); } +export async function getAppSupportInfo(): Promise { + return invoke("get_app_support_info"); +} + // --- Redis --- export interface RedisKeyInfo { key_display: string; diff --git a/packages/app-tests/supportInfo.test.ts b/packages/app-tests/supportInfo.test.ts new file mode 100644 index 000000000..82c247d52 --- /dev/null +++ b/packages/app-tests/supportInfo.test.ts @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, normalizeSupportInfoVersion } from "../../apps/desktop/src/lib/app/supportInfo.ts"; +import type { AppSupportInfoLabels } from "../../apps/desktop/src/lib/app/supportInfo.ts"; +import type { AppSupportInfo } from "../../apps/desktop/src/lib/backend/tauri.ts"; + +const labels: AppSupportInfoLabels = { + appVersion: "DBX Version", + runtime: "Runtime", + runtimeDesktop: "Desktop", + runtimeWeb: "Web", + operatingSystem: "Operating System", + architecture: "Architecture", + unknown: "Unknown", +}; + +test("normalizes support info versions with a single v prefix", () => { + assert.equal(normalizeSupportInfoVersion("0.5.50", labels.unknown), "v0.5.50"); + assert.equal(normalizeSupportInfoVersion("v0.5.50", labels.unknown), "v0.5.50"); + assert.equal(normalizeSupportInfoVersion("V0.5.50", labels.unknown), "v0.5.50"); +}); + +test("support info rows use stable fallback values", () => { + const info: AppSupportInfo = { + appVersion: "", + runtime: "desktop", + osName: "", + osVersion: null, + arch: "", + }; + + assert.deepEqual( + buildAppSupportInfoRows(info, labels).map((row) => row.value), + ["Unknown", "Desktop", "Unknown", "Unknown"], + ); +}); + +test("formats support info clipboard text in stable issue-friendly order", () => { + const info: AppSupportInfo = { + appVersion: "0.5.50", + runtime: "desktop", + osName: "macOS", + osVersion: "15.5", + arch: "aarch64", + }; + + assert.equal( + formatAppSupportInfoForClipboard(info, labels), + ["DBX Version: v0.5.50", "Runtime: Desktop", "Operating System: macOS 15.5", "Architecture: aarch64"].join("\n"), + ); +}); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 622952cd8..1368488c8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -36,6 +36,7 @@ pub mod schema_diff; pub mod sql_file; pub mod sqlite_backup; pub mod ssh_config; +pub mod support_info; pub mod system_fonts; pub mod tab_runtime_cache; pub mod table_export; diff --git a/src-tauri/src/commands/support_info.rs b/src-tauri/src/commands/support_info.rs new file mode 100644 index 000000000..3f79013a5 --- /dev/null +++ b/src-tauri/src/commands/support_info.rs @@ -0,0 +1,232 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSupportInfo { + pub app_version: String, + pub runtime: &'static str, + pub os_name: String, + pub os_version: Option, + pub arch: String, +} + +#[tauri::command] +pub async fn get_app_support_info() -> AppSupportInfo { + current_app_support_info() +} + +pub(crate) fn current_app_support_info() -> AppSupportInfo { + AppSupportInfo { + app_version: env!("CARGO_PKG_VERSION").to_string(), + runtime: "desktop", + os_name: os_name(), + os_version: os_version(), + arch: std::env::consts::ARCH.to_string(), + } +} + +pub(crate) fn format_support_info_for_native_about() -> String { + let info = current_app_support_info(); + let operating_system = format_operating_system(&info); + + ["Desktop".to_string(), unknown_if_empty(&operating_system), unknown_if_empty(&info.arch)].join(" • ") +} + +pub(crate) fn format_support_info_for_clipboard() -> String { + let info = current_app_support_info(); + let operating_system = format_operating_system(&info); + + [ + format!("DBX Version: {}", normalize_app_version(&info.app_version)), + "Runtime: Desktop".to_string(), + format!("Operating System: {}", unknown_if_empty(&operating_system)), + format!("Architecture: {}", unknown_if_empty(&info.arch)), + ] + .join("\n") +} + +fn format_operating_system(info: &AppSupportInfo) -> String { + let operating_system = [Some(info.os_name.as_str()), info.os_version.as_deref()] + .into_iter() + .flatten() + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" "); + operating_system +} + +fn normalize_app_version(version: &str) -> String { + let trimmed = version.trim(); + if trimmed.is_empty() { + return "Unknown".to_string(); + } + if trimmed.starts_with('v') || trimmed.starts_with('V') { + format!("v{}", &trimmed[1..]) + } else { + format!("v{trimmed}") + } +} + +fn unknown_if_empty(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "Unknown".to_string() + } else { + trimmed.to_string() + } +} + +fn os_name() -> String { + platform_product_name().unwrap_or_else(|| std::env::consts::OS.to_string()) +} + +fn os_version() -> Option { + platform_product_version() +} + +#[cfg(target_os = "macos")] +fn platform_product_name() -> Option { + command_first_line("sw_vers", &["-productName"]) +} + +#[cfg(target_os = "macos")] +fn platform_product_version() -> Option { + command_first_line("sw_vers", &["-productVersion"]) +} + +#[cfg(target_os = "windows")] +fn platform_product_name() -> Option { + Some("Windows".to_string()) +} + +#[cfg(target_os = "windows")] +fn platform_product_version() -> Option { + command_first_line("cmd", &["/C", "ver"]) + .map(|line| line.trim_matches(|ch| ch == '\r' || ch == '\n').trim().to_string()) +} + +#[cfg(target_os = "linux")] +fn platform_product_name() -> Option { + linux_os_release_value("/etc/os-release", "PRETTY_NAME") +} + +#[cfg(target_os = "linux")] +fn platform_product_version() -> Option { + linux_os_release_value("/etc/os-release", "VERSION_ID") +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn platform_product_name() -> Option { + None +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn platform_product_version() -> Option { + None +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn command_first_line(command: &str, args: &[&str]) -> Option { + let output = std::process::Command::new(command).args(args).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout).lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned) +} + +#[cfg(target_os = "linux")] +fn linux_os_release_value(path: &str, key: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; + parse_os_release_value(&content, key) +} + +#[cfg(any(target_os = "linux", test))] +fn parse_os_release_value(content: &str, key: &str) -> Option { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((name, value)) = line.split_once('=') else { + continue; + }; + if name != key { + continue; + } + return Some(unquote_os_release_value(value.trim())); + } + None +} + +#[cfg(any(target_os = "linux", test))] +fn unquote_os_release_value(value: &str) -> String { + let quoted = value + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|inner| inner.strip_suffix('\''))); + let Some(inner) = quoted else { + return value.to_string(); + }; + inner.replace("\\\"", "\"").replace("\\'", "'").replace("\\\\", "\\") +} + +#[cfg(test)] +mod tests { + use super::{ + format_support_info_for_clipboard, format_support_info_for_native_about, normalize_app_version, + parse_os_release_value, unknown_if_empty, unquote_os_release_value, + }; + + #[test] + fn normalizes_app_version_for_support_info() { + assert_eq!(normalize_app_version("0.5.50"), "v0.5.50"); + assert_eq!(normalize_app_version("v0.5.50"), "v0.5.50"); + assert_eq!(normalize_app_version("V0.5.50"), "v0.5.50"); + assert_eq!(normalize_app_version(""), "Unknown"); + } + + #[test] + fn formats_unknown_for_empty_support_info_values() { + assert_eq!(unknown_if_empty(""), "Unknown"); + assert_eq!(unknown_if_empty(" aarch64 "), "aarch64"); + } + + #[test] + fn formats_native_about_support_info_compactly() { + let text = format_support_info_for_native_about(); + assert!(text.contains("Desktop")); + assert!(!text.contains("DBX Version:")); + assert!(!text.contains('\n')); + } + + #[test] + fn formats_clipboard_support_info_with_labels() { + let text = format_support_info_for_clipboard(); + assert!(text.contains("DBX Version:")); + assert!(text.contains("Runtime:")); + assert!(text.contains("Operating System:")); + assert!(text.contains("Architecture:")); + } + + #[test] + fn parses_linux_os_release_values() { + let content = r#" +NAME="Ubuntu" +VERSION_ID="24.04" +PRETTY_NAME="Ubuntu 24.04.1 LTS" +"#; + + assert_eq!(parse_os_release_value(content, "PRETTY_NAME").as_deref(), Some("Ubuntu 24.04.1 LTS")); + assert_eq!(parse_os_release_value(content, "VERSION_ID").as_deref(), Some("24.04")); + } + + #[test] + fn returns_none_for_missing_linux_os_release_value() { + assert_eq!(parse_os_release_value("NAME=Ubuntu\n", "PRETTY_NAME"), None); + } + + #[test] + fn unquotes_linux_os_release_escapes() { + assert_eq!(unquote_os_release_value(r#""A \"quoted\" value""#), "A \"quoted\" value"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f90dff0b6..f5a6098a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,8 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, }; use tauri::{Emitter, Manager}; +#[cfg(target_os = "macos")] +use tauri_plugin_clipboard_manager::ClipboardExt; #[cfg(any(windows, target_os = "linux"))] use tauri_plugin_deep_link::DeepLinkExt; @@ -28,6 +30,8 @@ const DESKTOP_TRAY_ID: &str = "main-tray"; const APP_CLOSE_REQUESTED_EVENT: &str = "dbx-app-close-requested"; #[cfg(target_os = "macos")] const APP_MENU_QUIT_ID: &str = "app-menu-quit"; +#[cfg(target_os = "macos")] +const APP_MENU_COPY_SUPPORT_INFO_ID: &str = "app-menu-copy-support-info"; pub struct CloseBehaviorState { confirmed_exit: AtomicBool, @@ -48,6 +52,8 @@ impl CloseBehaviorState { } #[cfg(target_os = "macos")] const MACOS_TRAY_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/tray-macos-template.png"); +#[cfg(target_os = "macos")] +const ABOUT_APP_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/icon.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) { @@ -80,15 +86,16 @@ fn native_window_decorations_override(target_os: &str) -> Option { #[cfg(target_os = "macos")] fn build_app_menu(app_handle: &tauri::AppHandle) -> tauri::Result> { let pkg_info = app_handle.package_info(); - let config = app_handle.config(); let app_name = pkg_info.name.clone(); let about_metadata = AboutMetadata { name: Some(app_name.clone()), version: Some(pkg_info.version.to_string()), - copyright: config.bundle.copyright.clone(), - authors: config.bundle.publisher.clone().map(|p| vec![p]), + copyright: Some(commands::support_info::format_support_info_for_native_about()), + icon: Some(ABOUT_APP_ICON), ..Default::default() }; + let copy_support_info_item = + MenuItem::with_id(app_handle, APP_MENU_COPY_SUPPORT_INFO_ID, "Copy Support Info", true, None::<&str>)?; let quit_item = MenuItem::with_id(app_handle, APP_MENU_QUIT_ID, format!("Quit {app_name}"), true, Some("Cmd+Q"))?; Menu::with_items( @@ -100,6 +107,7 @@ fn build_app_menu(app_handle: &tauri::AppHandle) -> tauri: true, &[ &PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?, + ©_support_info_item, &PredefinedMenuItem::separator(app_handle)?, &PredefinedMenuItem::services(app_handle, None)?, &PredefinedMenuItem::separator(app_handle)?, @@ -626,6 +634,10 @@ pub fn run() { let builder = builder.menu(build_app_menu).on_menu_event(|app, event| { if event.id() == APP_MENU_QUIT_ID { request_app_close(app, "quit"); + } else if event.id() == APP_MENU_COPY_SUPPORT_INFO_ID { + if let Err(err) = app.clipboard().write_text(commands::support_info::format_support_info_for_clipboard()) { + log::warn!("Failed to copy support info from app menu: {err}"); + } } }); @@ -774,6 +786,7 @@ pub fn run() { commands::app_settings::load_saved_sql_editor_positions, commands::app_settings::save_saved_sql_editor_positions, commands::app_settings::load_native_debug_logs, + commands::support_info::get_app_support_info, commands::cloud_sync::webdav_sync_test, commands::cloud_sync::webdav_password_status, commands::cloud_sync::save_webdav_saved_password,