feat(readonly): add read-only mode for all database connections

- Add read_only field to ConnectionConfig with backward-compatible serde defaults
- SQL classifier with first-keyword + embedded keyword defense layers
- PRAGMA white-listing for safe read-only operations
- Guard all write entry points: SQL, Mongo, Redis, etcd, MCP Bridge
- 16 unit tests covering edge cases (CTE, case insensitivity, string masking)
- 7-language i18n support
- Fast-fail at command/route level before pool creation

Closes #889
This commit is contained in:
t8y2 committed 2026-06-10 11:58:18 +08:00
commit bfec556e9b
533 files changed
+11710 -20243

No files matched your search

+8 -2
View File
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use dbx_core::storage::DesktopSettings;
use tauri::{AppHandle, Manager, State};
@@ -37,9 +37,15 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc<AppState>>, ids: Vec
#[tauri::command]
pub async fn load_native_debug_logs(app: AppHandle) -> Result<String, String> {
let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?;
tauri::async_runtime::spawn_blocking(move || load_native_debug_logs_from_dir(log_dir))
.await
.map_err(|err| err.to_string())?
}
fn load_native_debug_logs_from_dir(log_dir: PathBuf) -> Result<String, String> {
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()));
}
+55 -3
View File
@@ -3,7 +3,7 @@ use tauri::State;
pub use dbx_core::agent_connection::{
agent_connect_params, mongo_legacy_error_with_auth_hint, oracle_alternate_connect_config,
oracle_auth_fallback_profiles, should_retry_oracle_with_10g_driver,
oracle_auth_fallback_profiles, oracle_error_with_driver_hint, should_retry_oracle_with_10g_driver,
};
pub use dbx_core::connection::{
connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_url_for_endpoint, metadata_connection_config,
@@ -85,7 +85,7 @@ async fn test_agent_connection(
));
}
} else {
return Err(err);
return Err(oracle_error_with_driver_hint(config, &err));
}
}
@@ -142,7 +142,7 @@ async fn connect_agent_pool(
format!("{err}\n\nFallback with legacy Oracle drivers failed: {}", fallback_errors.join("\n"))
})?;
} else {
return Err(err);
return Err(oracle_error_with_driver_hint(config, &err));
}
}
@@ -400,6 +400,31 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
.await
.map(|_| "Connection successful".to_string())
}
DatabaseType::Turso => {
let auth_token = if !config.password.is_empty() {
config.password.clone()
} else {
config
.url_params
.as_deref()
.and_then(|p| {
p.trim()
.trim_start_matches('?')
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(key, _)| {
let k = key.trim().to_ascii_lowercase();
k == "auth_token" || k == "authtoken" || k == "auth-token"
})
.map(|(_, value)| value.trim().to_string())
})
.unwrap_or_default()
};
let client = db::turso_driver::TursoClient::new(&url, &auth_token, config.ssl, connect_timeout)?;
db::turso_driver::test_connection(&client, connect_timeout)
.await
.map(|_| "Connection successful".to_string())
}
db_type if database_capabilities::is_agent_type(&db_type) => {
test_agent_connection(state.inner(), &config, &host, port).await
}
@@ -567,6 +592,30 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
db::rqlite_driver::test_connection(&client, connect_timeout).await?;
PoolKind::Rqlite(client)
}
DatabaseType::Turso => {
let auth_token = if !db_config.password.is_empty() {
db_config.password.clone()
} else {
db_config
.url_params
.as_deref()
.and_then(|p| {
p.trim()
.trim_start_matches('?')
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(key, _)| {
let k = key.trim().to_ascii_lowercase();
k == "auth_token" || k == "authtoken" || k == "auth-token"
})
.map(|(_, value)| value.trim().to_string())
})
.unwrap_or_default()
};
let client = db::turso_driver::TursoClient::new(&url, &auth_token, db_config.ssl, connect_timeout)?;
db::turso_driver::test_connection(&client, connect_timeout).await?;
PoolKind::Turso(client)
}
db_type if database_capabilities::is_agent_type(&db_type) => {
connect_agent_pool(state.inner(), &db_config, &host, port).await?
}
@@ -610,6 +659,9 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
}
drop(conns);
state.reset_connection_transport(&connection_id).await;
if connection_id.starts_with("__visible_draft_") {
state.configs.write().await.remove(&connection_id);
}
Ok(())
}
+7 -3
View File
@@ -15,9 +15,13 @@ pub struct QueryResultCsvExportRequest {
}
#[tauri::command]
pub fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> {
let csv = format_csv(&request.columns, &request.rows);
std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string())
pub async fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let csv = format_csv(&request.columns, &request.rows);
std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string())
})
.await
.map_err(|err| err.to_string())?
}
#[tauri::command]
+12 -3
View File
@@ -12,8 +12,8 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) ->
}
#[tauri::command]
pub fn read_external_sql_file(path: String) -> Result<String, String> {
read_external_sql_file_content(Path::new(&path))
pub async fn read_external_sql_file(path: String) -> Result<String, String> {
read_external_sql_file_content_async(PathBuf::from(path)).await
}
#[derive(Default)]
@@ -62,7 +62,8 @@ pub fn is_sql_file_path(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false)
}
pub fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
#[cfg(test)]
fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
if !is_sql_file_path(path) {
return Err("Only .sql files can be opened this way".to_string());
}
@@ -70,6 +71,14 @@ pub fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
decode_sql_file_bytes(&bytes)
}
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<String, String> {
if !is_sql_file_path(&path) {
return Err("Only .sql files can be opened this way".to_string());
}
let bytes = tokio::fs::read(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
decode_sql_file_bytes(&bytes)
}
fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
let mut unique = Vec::new();
for path in paths {
+2 -1
View File
@@ -14,8 +14,9 @@ pub async fn load_history(
state: State<'_, Arc<AppState>>,
limit: usize,
offset: usize,
activity_kind: Option<String>,
) -> Result<Vec<HistoryEntry>, String> {
state.storage.load_history_entries(limit, offset).await
state.storage.load_history_entries(limit, offset, activity_kind).await
}
#[tauri::command]
+55
View File
@@ -0,0 +1,55 @@
/// Read a macOS Keychain generic password by service name.
/// Triggers a system authorization dialog (Touch ID / password) for each unique service.
#[tauri::command]
pub async fn read_keychain_password(service: String, account: Option<String>) -> Result<String, String> {
#[cfg(not(target_os = "macos"))]
{
let _ = (service, account);
return Err("Keychain access is only available on macOS".to_string());
}
#[cfg(target_os = "macos")]
{
tauri::async_runtime::spawn_blocking(move || read_keychain_password_blocking(service, account))
.await
.map_err(|err| err.to_string())?
}
}
#[cfg(target_os = "macos")]
fn read_keychain_password_blocking(service: String, account: Option<String>) -> Result<String, String> {
let mut cmd = std::process::Command::new("security");
cmd.args(["find-generic-password", "-s", &service, "-w"]);
if let Some(ref acct) = account {
cmd.args(["-a", acct]);
}
let output = cmd.output().map_err(|e| format!("Failed to run security command: {e}"))?;
if output.status.success() {
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(password)
} else {
// Exit code 44 = user cancelled the authorization dialog
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.code() == Some(44) || stderr.contains("User canceled") {
Ok(String::new())
} else if stderr.contains("could not be found") || stderr.contains("The specified item could not be found") {
Ok(String::new())
} else {
Err(format!("Keychain read failed: {}", stderr.trim()))
}
}
}
/// Read multiple Keychain passwords in one call. Returns a map of service -> password.
/// Services that fail or are cancelled get an empty string.
#[tauri::command]
pub async fn read_keychain_passwords(services: Vec<String>) -> Result<Vec<(String, String)>, String> {
let mut results = Vec::with_capacity(services.len());
for service in services {
let password = read_keychain_password(service.clone(), None).await.unwrap_or_default();
results.push((service, password));
}
Ok(results)
}
+14 -6
View File
@@ -31,11 +31,17 @@ struct NpmLatestPackage {
#[tauri::command]
pub async fn check_mcp_server_status() -> Result<McpServerStatus, String> {
let npm_available = command_success("npm", &["--version"]);
let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line);
let current_version = if npm_available { installed_mcp_version() } else { None };
let bin_path = locate_mcp_bin();
let latest_version = fetch_latest_mcp_version().await.ok();
let local_status = tauri::async_runtime::spawn_blocking(|| {
let npm_available = command_success("npm", &["--version"]);
let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line);
let current_version = if npm_available { installed_mcp_version() } else { None };
let bin_path = locate_mcp_bin();
(npm_available, node_version, current_version, bin_path)
});
let latest_version = fetch_latest_mcp_version();
let (local_status, latest_version) = tokio::join!(local_status, latest_version);
let (npm_available, node_version, current_version, bin_path) = local_status.map_err(|err| err.to_string())?;
let latest_version = latest_version.ok();
let update_available = current_version
.as_deref()
.zip(latest_version.as_deref())
@@ -58,7 +64,9 @@ pub async fn check_mcp_server_status() -> Result<McpServerStatus, String> {
async fn fetch_latest_mcp_version() -> Result<String, String> {
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker");
if let Some(proxy_url) = dbx_core::update::system_proxy_url() {
let proxy_url =
tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.map_err(|e| e.to_string())?;
if let Some(proxy_url) = proxy_url {
let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid system proxy URL: {e}"))?;
builder = builder.proxy(proxy);
}
+1
View File
@@ -13,6 +13,7 @@ pub mod etcd_cmd;
pub mod external_db;
pub mod external_sql;
pub mod history;
pub mod keychain;
pub mod mcp;
pub mod mcp_bridge;
pub mod mongo_cmd;
+20 -5
View File
@@ -8,7 +8,10 @@ use super::connection::AppState;
#[tauri::command]
pub async fn list_plugins(state: State<'_, Arc<AppState>>) -> Result<Vec<InstalledPlugin>, String> {
state.plugins.list_installed()
let root_dir = state.plugins.root_dir().to_path_buf();
tauri::async_runtime::spawn_blocking(move || dbx_core::plugins::PluginRegistry::new(root_dir).list_installed())
.await
.map_err(|err| err.to_string())?
}
#[tauri::command]
@@ -31,12 +34,18 @@ pub async fn install_jdbc_plugin_local(
#[tauri::command]
pub async fn uninstall_jdbc_plugin(state: State<'_, Arc<AppState>>) -> Result<JdbcPluginStatus, String> {
jdbc::uninstall_jdbc_plugin(state.plugins.root_dir())
let root_dir = state.plugins.root_dir().to_path_buf();
tauri::async_runtime::spawn_blocking(move || jdbc::uninstall_jdbc_plugin(&root_dir))
.await
.map_err(|err| err.to_string())?
}
#[tauri::command]
pub async fn list_jdbc_drivers(state: State<'_, Arc<AppState>>) -> Result<Vec<JdbcDriverInfo>, String> {
jdbc::list_jdbc_drivers(state.plugins.root_dir())
let root_dir = state.plugins.root_dir().to_path_buf();
tauri::async_runtime::spawn_blocking(move || jdbc::list_jdbc_drivers(&root_dir))
.await
.map_err(|err| err.to_string())?
}
#[tauri::command]
@@ -44,10 +53,16 @@ pub async fn import_jdbc_drivers(
state: State<'_, Arc<AppState>>,
paths: Vec<String>,
) -> Result<Vec<JdbcDriverInfo>, String> {
jdbc::import_jdbc_drivers(state.plugins.root_dir(), &paths)
let root_dir = state.plugins.root_dir().to_path_buf();
tauri::async_runtime::spawn_blocking(move || jdbc::import_jdbc_drivers(&root_dir, &paths))
.await
.map_err(|err| err.to_string())?
}
#[tauri::command]
pub async fn delete_jdbc_driver(state: State<'_, Arc<AppState>>, path: String) -> Result<Vec<JdbcDriverInfo>, String> {
jdbc::delete_jdbc_driver(state.plugins.root_dir(), &path)
let root_dir = state.plugins.root_dir().to_path_buf();
tauri::async_runtime::spawn_blocking(move || jdbc::delete_jdbc_driver(&root_dir, &path))
.await
.map_err(|err| err.to_string())?
}
+42 -4
View File
@@ -81,9 +81,10 @@ pub async fn redis_hash_set(
key_raw: String,
field: String,
value: String,
ttl: Option<i64>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "HSET").await?;
dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value).await
dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value, ttl).await
}
#[tauri::command]
@@ -105,9 +106,10 @@ pub async fn redis_list_push(
db: u32,
key_raw: String,
value: String,
ttl: Option<i64>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "LPUSH").await?;
dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value).await
dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
}
#[tauri::command]
@@ -142,9 +144,10 @@ pub async fn redis_set_add(
db: u32,
key_raw: String,
member: String,
ttl: Option<i64>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "SADD").await?;
dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member).await
dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member, ttl).await
}
#[tauri::command]
@@ -167,9 +170,10 @@ pub async fn redis_zadd(
key_raw: String,
member: String,
score: f64,
ttl: Option<i64>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "ZADD").await?;
dbx_core::redis_ops::redis_zadd_in_db_core(&state, &connection_id, db, &key_raw, &member, score).await
dbx_core::redis_ops::redis_zadd_in_db_core(&state, &connection_id, db, &key_raw, &member, score, ttl).await
}
#[tauri::command]
@@ -184,6 +188,40 @@ pub async fn redis_zrem(
dbx_core::redis_ops::redis_zrem_in_db_core(&state, &connection_id, db, &key_raw, &member).await
}
#[tauri::command]
pub async fn redis_stream_add(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
key_raw: String,
entry_id: String,
fields: Vec<(String, String)>,
ttl: Option<i64>,
) -> Result<(), String> {
dbx_core::redis_ops::redis_stream_add_in_db_core(&state, &connection_id, db, &key_raw, &entry_id, fields, ttl).await
}
#[tauri::command]
pub async fn redis_json_set(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
key_raw: String,
value: String,
ttl: Option<i64>,
) -> Result<(), String> {
dbx_core::redis_ops::redis_json_set_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await
}
#[tauri::command]
pub async fn redis_check_json_module(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
) -> Result<bool, String> {
dbx_core::redis_ops::redis_check_json_module_in_db_core(&state, &connection_id, db).await
}
#[tauri::command]
pub async fn redis_set_ttl(
state: State<'_, Arc<AppState>>,
+184
View File
@@ -1,9 +1,38 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::State;
use dbx_core::connection::AppState;
use dbx_core::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
#[derive(Clone)]
pub struct SavedSqlStorageState {
pub data_dir: PathBuf,
}
const SYNC_MANIFEST_FILE: &str = ".dbx-sql-library-sync.json";
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedSqlSyncEntry {
pub folder_name: Option<String>,
pub file_name: String,
pub sql: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedSqlSyncRequest {
pub target_dir: String,
pub entries: Vec<SavedSqlSyncEntry>,
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct SavedSqlSyncManifest {
files: Vec<String>,
}
#[tauri::command]
pub async fn load_saved_sql_library(state: State<'_, Arc<AppState>>) -> Result<SavedSqlLibrary, String> {
state.storage.load_saved_sql_library().await
@@ -33,3 +62,158 @@ pub async fn save_saved_sql_file(state: State<'_, Arc<AppState>>, file: SavedSql
pub async fn delete_saved_sql_file(state: State<'_, Arc<AppState>>, id: String) -> Result<(), String> {
state.storage.delete_saved_sql_file(&id).await
}
#[tauri::command]
pub async fn saved_sql_storage_dir(state: State<'_, SavedSqlStorageState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn open_saved_sql_storage_dir(
state: State<'_, SavedSqlStorageState>,
dir: Option<String>,
) -> Result<(), String> {
let target_dir = dir
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| state.data_dir.clone());
std::fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?;
open_path(&target_dir)
}
#[tauri::command]
pub async fn sync_saved_sql_directory(request: SavedSqlSyncRequest) -> Result<(), String> {
let target_dir = PathBuf::from(request.target_dir.trim());
if target_dir.as_os_str().is_empty() {
return Err("Target directory is empty".to_string());
}
tokio::task::spawn_blocking(move || sync_saved_sql_directory_blocking(&target_dir, &request.entries))
.await
.map_err(|e| e.to_string())?
}
fn sync_saved_sql_directory_blocking(target_dir: &Path, entries: &[SavedSqlSyncEntry]) -> Result<(), String> {
let sync_root = target_dir.join("dbx-sql-library");
std::fs::create_dir_all(&sync_root).map_err(|e| e.to_string())?;
remove_previous_sync_files(&sync_root)?;
let mut written_files = Vec::new();
for entry in entries {
let mut file_dir = sync_root.to_path_buf();
if let Some(folder_name) = entry.folder_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
file_dir.push(sanitize_file_segment(folder_name));
}
std::fs::create_dir_all(&file_dir).map_err(|e| e.to_string())?;
let file_name = ensure_sql_extension(&sanitize_file_segment(&entry.file_name));
let file_path = unique_file_path(&file_dir, &file_name);
std::fs::write(&file_path, &entry.sql).map_err(|e| e.to_string())?;
if let Ok(relative) = file_path.strip_prefix(&sync_root) {
written_files.push(relative.to_string_lossy().replace('\\', "/"));
}
}
let manifest = SavedSqlSyncManifest { files: written_files };
let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
std::fs::write(sync_root.join(SYNC_MANIFEST_FILE), manifest_json).map_err(|e| e.to_string())?;
Ok(())
}
fn remove_previous_sync_files(target_dir: &Path) -> Result<(), String> {
let manifest_path = target_dir.join(SYNC_MANIFEST_FILE);
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
return Ok(());
};
let manifest = serde_json::from_str::<SavedSqlSyncManifest>(&raw).unwrap_or_default();
for relative in manifest.files {
let file_path = target_dir.join(relative);
if file_path.is_file() {
std::fs::remove_file(&file_path).map_err(|e| e.to_string())?;
remove_empty_parent_dirs(target_dir, file_path.parent());
}
}
Ok(())
}
fn remove_empty_parent_dirs(root: &Path, parent: Option<&Path>) {
let Some(dir) = parent else {
return;
};
if dir == root {
return;
}
if std::fs::remove_dir(dir).is_ok() {
remove_empty_parent_dirs(root, dir.parent());
}
}
fn sanitize_file_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| match ch {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
ch if ch.is_control() => '_',
ch => ch,
})
.collect::<String>()
.trim()
.trim_matches('.')
.to_string();
if sanitized.is_empty() {
"untitled".to_string()
} else {
sanitized
}
}
fn ensure_sql_extension(name: &str) -> String {
if name.to_lowercase().ends_with(".sql") {
name.to_string()
} else {
format!("{name}.sql")
}
}
fn unique_file_path(dir: &Path, file_name: &str) -> PathBuf {
let mut candidate = dir.join(file_name);
if !candidate.exists() {
return candidate;
}
let base = file_name.strip_suffix(".sql").unwrap_or(file_name);
let mut counter = 2;
loop {
candidate = dir.join(format!("{base} ({counter}).sql"));
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
fn open_path(path: &Path) -> Result<(), String> {
#[cfg(target_os = "macos")]
let mut command = {
let mut command = std::process::Command::new("open");
command.arg(path);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = std::process::Command::new("explorer");
command.arg(path);
command
};
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
let mut command = {
let mut command = std::process::Command::new("xdg-open");
command.arg(path);
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
+4 -2
View File
@@ -1,4 +1,6 @@
#[tauri::command]
pub fn list_system_fonts() -> Result<Vec<String>, String> {
Ok(dbx_core::jdbc::list_system_fonts())
pub async fn list_system_fonts() -> Result<Vec<String>, String> {
tauri::async_runtime::spawn_blocking(|| Ok(dbx_core::jdbc::list_system_fonts()))
.await
.map_err(|err| err.to_string())?
}
+18 -8
View File
@@ -11,19 +11,29 @@ pub struct QueryResultTextExportRequest {
}
impl QueryResultTextExportRequest {
fn data(&self) -> QueryResultTextExportData {
QueryResultTextExportData { columns: self.columns.clone(), rows: self.rows.clone() }
fn into_data(self) -> QueryResultTextExportData {
QueryResultTextExportData { columns: self.columns, rows: self.rows }
}
}
#[tauri::command]
pub fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> {
let content = format_json(&request.data())?;
std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
pub async fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let file_path = request.file_path.clone();
let content = format_json(&request.into_data())?;
std::fs::write(file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
})
.await
.map_err(|err| err.to_string())?
}
#[tauri::command]
pub fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> {
let content = format_markdown(&request.data());
std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
pub async fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let file_path = request.file_path.clone();
let content = format_markdown(&request.into_data());
std::fs::write(file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string())
})
.await
.map_err(|err| err.to_string())?
}
+2 -2
View File
@@ -10,6 +10,6 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
}
#[tauri::command]
pub fn get_system_proxy_url() -> Option<String> {
dbx_core::update::system_proxy_url()
pub async fn get_system_proxy_url() -> Option<String> {
tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.ok().flatten()
}
+11 -7
View File
@@ -12,11 +12,15 @@ pub struct QueryResultXlsxExportRequest {
}
#[tauri::command]
pub fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> {
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
sheet_name: request.sheet_name,
columns: request.columns,
rows: request.rows,
})?;
std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string())
pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
sheet_name: request.sheet_name,
columns: request.columns,
rows: request.rows,
})?;
std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string())
})
.await
.map_err(|err| err.to_string())?
}