feat(dev): support running alongside installed DBX
This commit is contained in:
parent
2318c48787
commit
eab263d3b4
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -313,6 +313,8 @@ make
|
|||
|
||||
`make` 会在需要时安装根目录依赖,并启动本地 Tauri 桌面端开发环境。
|
||||
|
||||
开发版可与已安装的 DBX 同时运行,并共享本地连接和历史数据。请避免在两个窗口中同时修改同一个连接或全局设置。
|
||||
|
||||
> [!TIP]
|
||||
> DuckDB 从源码编译较慢。如果不涉及 DuckDB 功能,可以跳过以加速本地构建:
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<Option<serde_json::Value>, 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 }]))
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ make
|
|||
make dev-fast
|
||||
```
|
||||
|
||||
开发版可以与已安装的 DBX 同时运行。两者使用同一份本地 DBX 数据,因此连接和历史记录会同时可用;请避免在两个窗口中同时修改同一个连接、收藏 SQL 或全局设置。
|
||||
|
||||
测试 MCP bridge 时,后启动的实例会写入共享的 `mcp-bridge-port` 发现文件并接收 MCP 请求。Redis PubSub 优先使用端口 `4224`(设置 `DBX_PORT` 时优先使用该端口);端口被占用时会自动回退到可用的本地端口,前端会使用实际监听端口。
|
||||
|
||||
### Windows
|
||||
|
||||
在 PowerShell 中执行:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
|
@ -115,12 +125,12 @@ pub async fn save_editor_settings(state: State<'_, Arc<AppState>>, settings: ser
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn load_open_tabs_state(state: State<'_, Arc<AppState>>) -> Result<Option<serde_json::Value>, 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<AppState>>, 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<String, String> {
|
|||
|
||||
#[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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u16>);
|
||||
|
||||
impl PubSubServerPort {
|
||||
fn new(port: Option<u16>) -> Self {
|
||||
Self(port)
|
||||
}
|
||||
|
||||
fn get(&self) -> Result<u16, String> {
|
||||
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<u16, String> {
|
||||
port.get()
|
||||
}
|
||||
|
||||
fn bind_pubsub_listener(preferred_port: u16) -> Result<TcpListener, String> {
|
||||
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<AppState>) {
|
||||
pub fn start_pubsub_server(state: Arc<AppState>) -> 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<AppState>) {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue