From eab263d3b46dea75d4fcc45dccaeae2f2fe24d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 23 Jul 2026 17:06:08 +0800 Subject: [PATCH] feat(dev): support running alongside installed DBX --- README.md | 2 + README.zh-CN.md | 2 + crates/dbx-core/src/storage.rs | 37 +++++++++ docs/content/docs/contributing.cn.mdx | 4 + docs/content/docs/contributing.mdx | 4 + src-tauri/Cargo.toml | 4 +- src-tauri/src/commands/app_settings.rs | 25 +++++- src-tauri/src/commands/redis_pubsub_server.rs | 79 +++++++++++++++++-- src-tauri/src/lib.rs | 58 ++++++++++++-- 9 files changed, 197 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3b1eaa430..40042a291 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,8 @@ make `make` installs root dependencies when needed and starts the local Tauri desktop development environment. +Development builds can run alongside an installed DBX instance and share its local data, including connections and history. Avoid changing the same connection or global setting in both windows at once. + > [!TIP] > DuckDB compilation takes a while. If you're not working on DuckDB features, > skip it to speed up local builds: diff --git a/README.zh-CN.md b/README.zh-CN.md index 3fcf1db09..9bfe24991 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -313,6 +313,8 @@ make `make` 会在需要时安装根目录依赖,并启动本地 Tauri 桌面端开发环境。 +开发版可与已安装的 DBX 同时运行,并共享本地连接和历史数据。请避免在两个窗口中同时修改同一个连接或全局设置。 + > [!TIP] > DuckDB 从源码编译较慢。如果不涉及 DuckDB 功能,可以跳过以加速本地构建: > diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 727d378e6..11d6f06c5 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -1631,6 +1631,19 @@ impl Storage { self.load_app_state_value(APP_STATE_OPEN_TABS_KEY).await } + /// Persist open tabs under an isolated state key while sharing the rest of the database. + /// + /// Desktop development builds use this to avoid overwriting the installed app's + /// in-progress SQL when both instances use the same data directory. + pub async fn save_open_tabs_state_with_key(&self, key: &str, state: &serde_json::Value) -> Result<(), String> { + self.save_app_state_value(key, state).await + } + + /// Load open tabs from an isolated state key. + pub async fn load_open_tabs_state_with_key(&self, key: &str) -> Result, String> { + self.load_app_state_value(key).await + } + pub async fn save_saved_sql_editor_positions(&self, positions: &serde_json::Value) -> Result<(), String> { self.save_app_state_value(APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY, positions).await } @@ -4486,6 +4499,30 @@ mod tests { storage.load_open_tabs_state().await.unwrap().and_then(|value| value.get("activeTabId").cloned()), Some(serde_json::json!("tab-1")) ); + + let development_open_tabs_key = "development_open_tabs"; + storage + .save_open_tabs_state_with_key( + development_open_tabs_key, + &serde_json::json!({ + "tabs": [{ "id": "tab-2", "title": "Development", "connectionId": "pg", "database": "app", "sql": "select 2" }], + "activeTabId": "tab-2" + }), + ) + .await + .unwrap(); + assert_eq!( + storage + .load_open_tabs_state_with_key(development_open_tabs_key) + .await + .unwrap() + .and_then(|value| value.get("activeTabId").cloned()), + Some(serde_json::json!("tab-2")) + ); + assert_eq!( + storage.load_open_tabs_state().await.unwrap().and_then(|value| value.get("activeTabId").cloned()), + Some(serde_json::json!("tab-1")) + ); assert_eq!( storage.load_saved_sql_editor_positions().await.unwrap(), Some(serde_json::json!([{ "savedSqlId": "file-1", "updatedAt": 1 }])) diff --git a/docs/content/docs/contributing.cn.mdx b/docs/content/docs/contributing.cn.mdx index 29567f07c..04f71b9ff 100644 --- a/docs/content/docs/contributing.cn.mdx +++ b/docs/content/docs/contributing.cn.mdx @@ -157,6 +157,10 @@ make make dev-fast ``` +开发版可以与已安装的 DBX 同时运行。两者使用同一份本地 DBX 数据,因此连接和历史记录会同时可用;请避免在两个窗口中同时修改同一个连接、收藏 SQL 或全局设置。 + +测试 MCP bridge 时,后启动的实例会写入共享的 `mcp-bridge-port` 发现文件并接收 MCP 请求。Redis PubSub 优先使用端口 `4224`(设置 `DBX_PORT` 时优先使用该端口);端口被占用时会自动回退到可用的本地端口,前端会使用实际监听端口。 + ### Windows 在 PowerShell 中执行: diff --git a/docs/content/docs/contributing.mdx b/docs/content/docs/contributing.mdx index 5c566bab6..bb4e63742 100644 --- a/docs/content/docs/contributing.mdx +++ b/docs/content/docs/contributing.mdx @@ -157,6 +157,10 @@ If your change does not involve DuckDB, skip the DuckDB source build: make dev-fast ``` +Development builds can run alongside an installed DBX instance. Both use the same local DBX data, so connections and history are available in each window. Avoid changing the same connection, saved SQL, or global setting in both windows at once. + +When testing the MCP bridge, the instance started last owns the shared `mcp-bridge-port` discovery file and receives MCP requests. Redis PubSub prefers port `4224` (or `DBX_PORT` when set) and automatically falls back to an available local port; the frontend uses the actual bound port. + ### Windows Run in PowerShell: diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4315ac884..e363bd49d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,8 +67,8 @@ tauri-plugin-clipboard-manager = "2.3.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false, features = ["std"] } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSView", "NSWindow"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSData"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSButton", "NSControl", "NSDockTile", "NSImage", "NSResponder", "NSView", "NSWindow"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSData", "NSString"] } [target.'cfg(target_os = "linux")'.dependencies] libloading = "0.8" diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index 40759b5e4..a86ade39f 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -11,6 +11,16 @@ use crate::{ apply_debug_log_level, apply_desktop_settings, hide_main_window_for_close, request_app_close, CloseBehaviorState, }; +const DEVELOPMENT_OPEN_TABS_STATE_KEY: &str = "development_open_tabs"; + +fn open_tabs_state_key(debug_build: bool) -> &'static str { + if debug_build { + DEVELOPMENT_OPEN_TABS_STATE_KEY + } else { + "open_tabs" + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DriverStoreMigrationResult { pub driver_store_dir: Option, @@ -115,12 +125,12 @@ pub async fn save_editor_settings(state: State<'_, Arc>, settings: ser #[tauri::command] pub async fn load_open_tabs_state(state: State<'_, Arc>) -> Result, String> { - state.storage.load_open_tabs_state().await + state.storage.load_open_tabs_state_with_key(open_tabs_state_key(cfg!(debug_assertions))).await } #[tauri::command] pub async fn save_open_tabs_state(state: State<'_, Arc>, payload: serde_json::Value) -> Result<(), String> { - state.storage.save_open_tabs_state(&payload).await + state.storage.save_open_tabs_state_with_key(open_tabs_state_key(cfg!(debug_assertions)), &payload).await } #[tauri::command] @@ -481,7 +491,10 @@ fn load_native_debug_logs_from_dir(log_dir: PathBuf) -> Result { #[cfg(test)] mod tests { - use super::{driver_store_migration_result, resolve_driver_store_dirs_from_settings, DesktopSettings}; + use super::{ + driver_store_migration_result, open_tabs_state_key, resolve_driver_store_dirs_from_settings, DesktopSettings, + DEVELOPMENT_OPEN_TABS_STATE_KEY, + }; use std::path::PathBuf; #[test] @@ -548,6 +561,12 @@ mod tests { assert_eq!(agents_dir, Some(PathBuf::from(path("D:/develop/DBX/agents")))); } + #[test] + fn isolates_open_tabs_for_development_builds() { + assert_eq!(open_tabs_state_key(true), DEVELOPMENT_OPEN_TABS_STATE_KEY); + assert_eq!(open_tabs_state_key(false), "open_tabs"); + } + fn path(value: &str) -> String { value.replace('/', std::path::MAIN_SEPARATOR_STR) } diff --git a/src-tauri/src/commands/redis_pubsub_server.rs b/src-tauri/src/commands/redis_pubsub_server.rs index dff120814..56195ea04 100644 --- a/src-tauri/src/commands/redis_pubsub_server.rs +++ b/src-tauri/src/commands/redis_pubsub_server.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::{net::Ipv4Addr, net::TcpListener}; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{Query, State, WebSocketUpgrade}; @@ -12,6 +13,18 @@ use dbx_core::connection::AppState; const DEFAULT_PUBSUB_PORT: u16 = 4224; +pub struct PubSubServerPort(Option); + +impl PubSubServerPort { + fn new(port: Option) -> Self { + Self(port) + } + + fn get(&self) -> Result { + self.0.ok_or_else(|| "Redis PubSub server is unavailable".to_string()) + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PubSubWsParams { @@ -27,8 +40,24 @@ fn pubsub_server_port() -> u16 { } #[tauri::command] -pub fn redis_pubsub_server_port() -> u16 { - pubsub_server_port() +pub fn redis_pubsub_server_port(port: tauri::State<'_, PubSubServerPort>) -> Result { + port.get() +} + +fn bind_pubsub_listener(preferred_port: u16) -> Result { + let preferred_addr = (Ipv4Addr::LOCALHOST, preferred_port); + match TcpListener::bind(preferred_addr) { + Ok(listener) => Ok(listener), + Err(preferred_error) if preferred_port != 0 => { + log::warn!( + "Failed to bind PubSub server on {preferred_addr:?}: {preferred_error}; using an available port instead" + ); + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|fallback_error| { + format!("Failed to bind PubSub server on an available port: {fallback_error}") + }) + } + Err(error) => Err(format!("Failed to bind PubSub server on {preferred_addr:?}: {error}")), + } } async fn ws_handler( @@ -144,15 +173,32 @@ async fn handle_command(sink: &mut redis::aio::PubSubSink, text: &str) -> Result /// Start the embedded web server for PubSub WebSocket support. /// Runs on a background task using the shared AppState. -pub fn start_pubsub_server(state: Arc) { +pub fn start_pubsub_server(state: Arc) -> PubSubServerPort { let router = build_pubsub_router(state); + let listener = match bind_pubsub_listener(pubsub_server_port()) { + Ok(listener) => listener, + Err(error) => { + log::warn!("{error}"); + return PubSubServerPort::new(None); + } + }; + let addr = match listener.local_addr() { + Ok(addr) => addr, + Err(error) => { + log::warn!("Failed to read PubSub server address: {error}"); + return PubSubServerPort::new(None); + } + }; + if let Err(error) = listener.set_nonblocking(true) { + log::warn!("Failed to configure PubSub server listener: {error}"); + return PubSubServerPort::new(None); + } + tauri::async_runtime::spawn(async move { - let port = pubsub_server_port(); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); - let listener = match tokio::net::TcpListener::bind(addr).await { + let listener = match tokio::net::TcpListener::from_std(listener) { Ok(listener) => listener, Err(error) => { - log::warn!("Failed to bind PubSub server on {addr}: {error}"); + log::warn!("Failed to start PubSub server on {addr}: {error}"); return; } }; @@ -161,4 +207,23 @@ pub fn start_pubsub_server(state: Arc) { log::warn!("PubSub server stopped with error: {error}"); } }); + + PubSubServerPort::new(Some(addr.port())) +} + +#[cfg(test)] +mod tests { + use super::bind_pubsub_listener; + use std::net::{Ipv4Addr, TcpListener}; + + #[test] + fn falls_back_to_an_available_local_port_when_the_preferred_port_is_in_use() { + let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let preferred_port = occupied.local_addr().unwrap().port(); + + let listener = bind_pubsub_listener(preferred_port).unwrap(); + + assert_eq!(listener.local_addr().unwrap().ip(), Ipv4Addr::LOCALHOST); + assert_ne!(listener.local_addr().unwrap().port(), preferred_port); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87ce73124..8f5a3abf5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,15 @@ fn should_setup_desktop_tray(target_os: &str, show_tray_icon: bool, linux_appind && (matches!(target_os, "macos" | "windows") || (target_os == "linux" && linux_appindicator_available)) } +fn should_enable_single_instance(debug_build: bool) -> bool { + !debug_build +} + +#[cfg(target_os = "macos")] +fn development_dock_badge_label(debug_build: bool) -> Option<&'static str> { + debug_build.then_some("DEV") +} + #[cfg(target_os = "linux")] fn linux_appindicator_available() -> bool { const APPINDICATOR_LIBRARIES: &[&str] = &["libayatana-appindicator3.so.1", "libappindicator3.so.1"]; @@ -545,6 +554,21 @@ fn apply_macos_app_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIconThe }) } +#[cfg(target_os = "macos")] +fn apply_macos_development_dock_badge(app: &tauri::AppHandle) -> tauri::Result<()> { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + use objc2_foundation::NSString; + + let badge_label = development_dock_badge_label(cfg!(debug_assertions)); + app.run_on_main_thread(move || { + let marker = unsafe { MainThreadMarker::new_unchecked() }; + let application = NSApplication::sharedApplication(marker); + let badge_label = badge_label.map(NSString::from_str); + application.dockTile().setBadgeLabel(badge_label.as_deref()); + }) +} + fn apply_desktop_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIconTheme) -> tauri::Result<()> { #[cfg(target_os = "macos")] { @@ -613,9 +637,9 @@ mod tests { use super::{ linux_appimage_system_gtk_immodules_cache, linux_appimage_wayland_backend_override, linux_nvidia_driver_from_state, linux_selected_drm_render_device, linux_webkit_rendering_workarounds, - native_window_decorations_override, should_confirm_app_exit_request, should_fallback_to_native_quit, - should_hide_window_on_close, should_setup_desktop_tray, should_show_main_window_after_setup, - uses_application_level_icon, LinuxDrmRenderDevice, LinuxNvidiaDriver, + native_window_decorations_override, should_confirm_app_exit_request, should_enable_single_instance, + should_fallback_to_native_quit, should_hide_window_on_close, should_setup_desktop_tray, + should_show_main_window_after_setup, uses_application_level_icon, LinuxDrmRenderDevice, LinuxNvidiaDriver, }; use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -644,6 +668,19 @@ mod tests { assert!(!should_setup_desktop_tray("linux", false, true)); } + #[test] + fn keeps_single_instance_for_release_builds_only() { + assert!(!should_enable_single_instance(true)); + assert!(should_enable_single_instance(false)); + } + + #[cfg(target_os = "macos")] + #[test] + fn labels_debug_builds_in_the_macos_dock() { + assert_eq!(super::development_dock_badge_label(true), Some("DEV")); + assert_eq!(super::development_dock_badge_label(false), None); + } + #[cfg(target_os = "macos")] #[test] fn macos_tray_icon_remains_a_system_template() { @@ -888,8 +925,10 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + .plugin(tauri_plugin_fs::init()); + + let builder = if should_enable_single_instance(cfg!(debug_assertions)) { + builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { let links = commands::deep_link::connection_deep_links_from_args(args.clone()); open_connection_deep_links(app, links); @@ -910,6 +949,11 @@ pub fn run() { } show_main_window(app); })) + } else { + builder + }; + + let builder = builder .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) @@ -1002,7 +1046,7 @@ pub fn run() { state.set_duckdb_worker_max_processes(desktop_settings.duckdb_worker_max_processes); let state = Arc::new(state); app.manage(state.clone()); - commands::redis_pubsub_server::start_pubsub_server(state.clone()); + app.manage(commands::redis_pubsub_server::start_pubsub_server(state.clone())); app.manage(commands::saved_sql::SavedSqlStorageState { data_dir: data_dir.clone() }); app.manage(commands::external_sql::ExternalSqlOpenState::default()); app.manage(commands::external_db::ExternalDbOpenState::default()); @@ -1030,6 +1074,8 @@ pub fn run() { setup_desktop_tray(app, desktop_settings.icon_theme)?; } apply_desktop_icon_theme(app.handle(), desktop_settings.icon_theme)?; + #[cfg(target_os = "macos")] + apply_macos_development_dock_badge(app.handle())?; window_state_guard::enforce_main_window_bounds(app.handle()); if should_show_main_window_after_setup() { show_main_window(app.handle());