feat: add Tauri v2 Rust backend with PostgreSQL support
Add Rust backend: AppState with connection pool management, TuskError handling, ConnectionConfig model, and all commands for connections, schema browsing, query execution, data CRUD, and CSV/JSON export via sqlx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5723
src-tauri/Cargo.lock
generated
Normal file
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "tusk"
|
||||
version = "0.1.0"
|
||||
description = "PostgreSQL Database Management GUI"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "tusk_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
csv = "1"
|
||||
log = "0.4"
|
||||
hex = "0.4"
|
||||
bigdecimal = { version = "0.4", features = ["serde"] }
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
142
src-tauri/src/commands/connections.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::state::AppState;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
use std::fs;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir.join("connections.json"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_connections(app: AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
|
||||
let path = get_connections_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let data = fs::read_to_string(&path)?;
|
||||
let connections: Vec<ConnectionConfig> = serde_json::from_str(&data)?;
|
||||
Ok(connections)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_connection(app: AppHandle, config: ConnectionConfig) -> TuskResult<()> {
|
||||
let path = get_connections_path(&app)?;
|
||||
let mut connections = if path.exists() {
|
||||
let data = fs::read_to_string(&path)?;
|
||||
serde_json::from_str::<Vec<ConnectionConfig>>(&data)?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
if let Some(pos) = connections.iter().position(|c| c.id == config.id) {
|
||||
connections[pos] = config;
|
||||
} else {
|
||||
connections.push(config);
|
||||
}
|
||||
|
||||
let data = serde_json::to_string_pretty(&connections)?;
|
||||
fs::write(&path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_connection(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> TuskResult<()> {
|
||||
let path = get_connections_path(&app)?;
|
||||
if path.exists() {
|
||||
let data = fs::read_to_string(&path)?;
|
||||
let mut connections: Vec<ConnectionConfig> = serde_json::from_str(&data)?;
|
||||
connections.retain(|c| c.id != id);
|
||||
let data = serde_json::to_string_pretty(&connections)?;
|
||||
fs::write(&path, data)?;
|
||||
}
|
||||
|
||||
// Close pool if connected
|
||||
let mut pools = state.pools.write().await;
|
||||
if let Some(pool) = pools.remove(&id) {
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
|
||||
let pool = PgPool::connect(&config.connection_url())
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
let row = sqlx::query("SELECT version()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
let version: String = row.get(0);
|
||||
pool.close().await;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> TuskResult<()> {
|
||||
let pool = PgPool::connect(&config.connection_url())
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
// Verify connection
|
||||
sqlx::query("SELECT 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
let mut pools = state.pools.write().await;
|
||||
pools.insert(config.id.clone(), pool);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn switch_database(
|
||||
state: State<'_, AppState>,
|
||||
config: ConnectionConfig,
|
||||
database: String,
|
||||
) -> TuskResult<()> {
|
||||
let mut switched = config.clone();
|
||||
switched.database = database;
|
||||
|
||||
let pool = PgPool::connect(&switched.connection_url())
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
sqlx::query("SELECT 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
let mut pools = state.pools.write().await;
|
||||
if let Some(old_pool) = pools.remove(&config.id) {
|
||||
old_pool.close().await;
|
||||
}
|
||||
pools.insert(config.id.clone(), pool);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<()> {
|
||||
let mut pools = state.pools.write().await;
|
||||
if let Some(pool) = pools.remove(&id) {
|
||||
pool.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
236
src-tauri/src/commands/data.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use crate::commands::queries::pg_value_to_json;
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::query_result::PaginatedQueryResult;
|
||||
use crate::state::AppState;
|
||||
use serde_json::Value;
|
||||
use sqlx::{Column, Row, TypeInfo};
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
fn escape_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_data(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
sort_column: Option<String>,
|
||||
sort_direction: Option<String>,
|
||||
filter: Option<String>,
|
||||
) -> TuskResult<PaginatedQueryResult> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||
|
||||
let mut where_clause = String::new();
|
||||
if let Some(ref f) = filter {
|
||||
if !f.trim().is_empty() {
|
||||
where_clause = format!(" WHERE {}", f);
|
||||
}
|
||||
}
|
||||
|
||||
let mut order_clause = String::new();
|
||||
if let Some(ref col) = sort_column {
|
||||
let dir = sort_direction.as_deref().unwrap_or("ASC");
|
||||
let dir = if dir.eq_ignore_ascii_case("desc") {
|
||||
"DESC"
|
||||
} else {
|
||||
"ASC"
|
||||
};
|
||||
order_clause = format!(" ORDER BY {} {}", escape_ident(col), dir);
|
||||
}
|
||||
|
||||
let offset = (page.saturating_sub(1)) * page_size;
|
||||
|
||||
let data_sql = format!(
|
||||
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
|
||||
qualified, where_clause, order_clause, page_size, offset
|
||||
);
|
||||
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let (rows, count_row) = tokio::try_join!(
|
||||
sqlx::query(&data_sql).fetch_all(pool),
|
||||
sqlx::query(&count_sql).fetch_one(pool),
|
||||
)
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
let execution_time_ms = start.elapsed().as_millis();
|
||||
let total_rows: i64 = count_row.get(0);
|
||||
|
||||
let mut columns = Vec::new();
|
||||
let mut types = Vec::new();
|
||||
|
||||
if let Some(first_row) = rows.first() {
|
||||
for col in first_row.columns() {
|
||||
columns.push(col.name().to_string());
|
||||
types.push(col.type_info().name().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let result_rows: Vec<Vec<Value>> = rows
|
||||
.iter()
|
||||
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
||||
.collect();
|
||||
|
||||
let row_count = result_rows.len();
|
||||
|
||||
Ok(PaginatedQueryResult {
|
||||
columns,
|
||||
types,
|
||||
rows: result_rows,
|
||||
row_count,
|
||||
execution_time_ms,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_row(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
pk_columns: Vec<String>,
|
||||
pk_values: Vec<Value>,
|
||||
column: String,
|
||||
value: Value,
|
||||
) -> TuskResult<()> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||
|
||||
let set_clause = format!("{} = $1", escape_ident(&column));
|
||||
|
||||
let where_parts: Vec<String> = pk_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2))
|
||||
.collect();
|
||||
let where_clause = where_parts.join(" AND ");
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE {} SET {} WHERE {}",
|
||||
qualified, set_clause, where_clause
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
query = bind_json_value(query, &value);
|
||||
for pk_val in &pk_values {
|
||||
query = bind_json_value(query, pk_val);
|
||||
}
|
||||
|
||||
query.execute(pool).await.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn insert_row(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
values: Vec<Value>,
|
||||
) -> TuskResult<()> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||
|
||||
let col_list: Vec<String> = columns.iter().map(|c| escape_ident(c)).collect();
|
||||
let placeholders: Vec<String> = (1..=columns.len()).map(|i| format!("${}", i)).collect();
|
||||
|
||||
let sql = format!(
|
||||
"INSERT INTO {} ({}) VALUES ({})",
|
||||
qualified,
|
||||
col_list.join(", "),
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
for val in &values {
|
||||
query = bind_json_value(query, val);
|
||||
}
|
||||
|
||||
query.execute(pool).await.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_rows(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
pk_columns: Vec<String>,
|
||||
pk_values_list: Vec<Vec<Value>>,
|
||||
) -> TuskResult<u64> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||
let mut total_affected: u64 = 0;
|
||||
|
||||
for pk_values in &pk_values_list {
|
||||
let where_parts: Vec<String> = pk_columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1))
|
||||
.collect();
|
||||
let where_clause = where_parts.join(" AND ");
|
||||
|
||||
let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
for val in pk_values {
|
||||
query = bind_json_value(query, val);
|
||||
}
|
||||
|
||||
let result = query.execute(pool).await.map_err(TuskError::Database)?;
|
||||
total_affected += result.rows_affected();
|
||||
}
|
||||
|
||||
Ok(total_affected)
|
||||
}
|
||||
|
||||
fn bind_json_value<'q>(
|
||||
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||
value: &'q Value,
|
||||
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
||||
match value {
|
||||
Value::Null => query.bind(None::<String>),
|
||||
Value::Bool(b) => query.bind(*b),
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
query.bind(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
query.bind(f)
|
||||
} else {
|
||||
query.bind(n.to_string())
|
||||
}
|
||||
}
|
||||
Value::String(s) => query.bind(s.as_str()),
|
||||
_ => query.bind(value.to_string()),
|
||||
}
|
||||
}
|
||||
58
src-tauri/src/commands/export.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_csv(
|
||||
path: String,
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
) -> TuskResult<()> {
|
||||
let file = File::create(&path)?;
|
||||
let mut wtr = csv::Writer::from_writer(file);
|
||||
|
||||
wtr.write_record(&columns)
|
||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||
|
||||
for row in &rows {
|
||||
let record: Vec<String> = row
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => String::new(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => v.to_string(),
|
||||
})
|
||||
.collect();
|
||||
wtr.write_record(&record)
|
||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||
}
|
||||
|
||||
wtr.flush().map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_json(
|
||||
path: String,
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
) -> TuskResult<()> {
|
||||
let objects: Vec<serde_json::Map<String, Value>> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
columns
|
||||
.iter()
|
||||
.zip(row.iter())
|
||||
.map(|(col, val)| (col.clone(), val.clone()))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let json = serde_json::to_string_pretty(&objects)?;
|
||||
let mut file = File::create(&path)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
5
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod connections;
|
||||
pub mod data;
|
||||
pub mod export;
|
||||
pub mod queries;
|
||||
pub mod schema;
|
||||
113
src-tauri/src/commands/queries.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::query_result::QueryResult;
|
||||
use crate::state::AppState;
|
||||
use serde_json::Value;
|
||||
use sqlx::postgres::PgRow;
|
||||
use sqlx::{Column, Row, TypeInfo};
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
||||
let col = &row.columns()[index];
|
||||
let type_name = col.type_info().name();
|
||||
|
||||
macro_rules! try_get {
|
||||
($t:ty) => {
|
||||
match row.try_get::<Option<$t>, _>(index) {
|
||||
Ok(Some(v)) => return serde_json::to_value(v).unwrap_or(Value::Null),
|
||||
Ok(None) => return Value::Null,
|
||||
Err(_) => {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match type_name {
|
||||
"BOOL" => try_get!(bool),
|
||||
"INT2" => try_get!(i16),
|
||||
"INT4" => try_get!(i32),
|
||||
"INT8" => try_get!(i64),
|
||||
"FLOAT4" => try_get!(f32),
|
||||
"FLOAT8" => try_get!(f64),
|
||||
"NUMERIC" => {
|
||||
try_get!(bigdecimal::BigDecimal);
|
||||
}
|
||||
"TEXT" | "VARCHAR" | "CHAR" | "BPCHAR" | "NAME" => try_get!(String),
|
||||
"JSON" | "JSONB" => try_get!(Value),
|
||||
"UUID" => try_get!(uuid::Uuid),
|
||||
"TIMESTAMP" => {
|
||||
try_get!(chrono::NaiveDateTime);
|
||||
}
|
||||
"TIMESTAMPTZ" => {
|
||||
try_get!(chrono::DateTime<chrono::Utc>);
|
||||
}
|
||||
"DATE" => try_get!(chrono::NaiveDate),
|
||||
"TIME" => try_get!(chrono::NaiveTime),
|
||||
"BYTEA" => {
|
||||
match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||
Ok(None) => return Value::Null,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
"OID" => {
|
||||
match row.try_get::<Option<i32>, _>(index) {
|
||||
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
||||
Ok(None) => return Value::Null,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
"VOID" => return Value::Null,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Fallback: try as string
|
||||
match row.try_get::<Option<String>, _>(index) {
|
||||
Ok(Some(v)) => Value::String(v),
|
||||
Ok(None) => Value::Null,
|
||||
Err(_) => Value::String(format!("<unsupported type: {}>", type_name)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_query(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
sql: String,
|
||||
) -> TuskResult<QueryResult> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let start = Instant::now();
|
||||
let rows = sqlx::query(&sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
let execution_time_ms = start.elapsed().as_millis();
|
||||
|
||||
let mut columns = Vec::new();
|
||||
let mut types = Vec::new();
|
||||
|
||||
if let Some(first_row) = rows.first() {
|
||||
for col in first_row.columns() {
|
||||
columns.push(col.name().to_string());
|
||||
types.push(col.type_info().name().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let result_rows: Vec<Vec<Value>> = rows
|
||||
.iter()
|
||||
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
||||
.collect();
|
||||
|
||||
let row_count = result_rows.len();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
types,
|
||||
rows: result_rows,
|
||||
row_count,
|
||||
execution_time_ms,
|
||||
})
|
||||
}
|
||||
342
src-tauri/src/commands/schema.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::schema::{ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
|
||||
use crate::state::AppState;
|
||||
use sqlx::Row;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_databases(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<String>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT datname FROM pg_database \
|
||||
WHERE datistemplate = false \
|
||||
ORDER BY datname",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_schemas(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<String>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
||||
ORDER BY schema_name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tables(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT table_name FROM information_schema.tables \
|
||||
WHERE table_schema = $1 AND table_type = 'BASE TABLE' \
|
||||
ORDER BY table_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| SchemaObject {
|
||||
name: r.get(0),
|
||||
object_type: "table".to_string(),
|
||||
schema: schema.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_views(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT table_name FROM information_schema.views \
|
||||
WHERE table_schema = $1 \
|
||||
ORDER BY table_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| SchemaObject {
|
||||
name: r.get(0),
|
||||
object_type: "view".to_string(),
|
||||
schema: schema.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_functions(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT routine_name FROM information_schema.routines \
|
||||
WHERE routine_schema = $1 AND routine_type = 'FUNCTION' \
|
||||
ORDER BY routine_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| SchemaObject {
|
||||
name: r.get(0),
|
||||
object_type: "function".to_string(),
|
||||
schema: schema.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_indexes(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT indexname FROM pg_indexes \
|
||||
WHERE schemaname = $1 \
|
||||
ORDER BY indexname",
|
||||
)
|
||||
.bind(&schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| SchemaObject {
|
||||
name: r.get(0),
|
||||
object_type: "index".to_string(),
|
||||
schema: schema.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sequences(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT sequence_name FROM information_schema.sequences \
|
||||
WHERE sequence_schema = $1 \
|
||||
ORDER BY sequence_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| SchemaObject {
|
||||
name: r.get(0),
|
||||
object_type: "sequence".to_string(),
|
||||
schema: schema.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_columns(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> TuskResult<Vec<ColumnInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT \
|
||||
c.column_name, \
|
||||
c.data_type, \
|
||||
c.is_nullable, \
|
||||
c.column_default, \
|
||||
c.ordinal_position::int, \
|
||||
c.character_maximum_length::int, \
|
||||
COALESCE(( \
|
||||
SELECT true FROM information_schema.table_constraints tc \
|
||||
JOIN information_schema.key_column_usage kcu \
|
||||
ON tc.constraint_name = kcu.constraint_name \
|
||||
AND tc.table_schema = kcu.table_schema \
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY' \
|
||||
AND tc.table_schema = $1 \
|
||||
AND tc.table_name = $2 \
|
||||
AND kcu.column_name = c.column_name \
|
||||
LIMIT 1 \
|
||||
), false) as is_pk \
|
||||
FROM information_schema.columns c \
|
||||
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||
ORDER BY c.ordinal_position",
|
||||
)
|
||||
.bind(&schema)
|
||||
.bind(&table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| ColumnInfo {
|
||||
name: r.get::<String, _>(0),
|
||||
data_type: r.get::<String, _>(1),
|
||||
is_nullable: r.get::<String, _>(2) == "YES",
|
||||
column_default: r.get::<Option<String>, _>(3),
|
||||
ordinal_position: r.get::<i32, _>(4),
|
||||
character_maximum_length: r.get::<Option<i32>, _>(5),
|
||||
is_primary_key: r.get::<bool, _>(6),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_constraints(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> TuskResult<Vec<ConstraintInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT \
|
||||
tc.constraint_name, \
|
||||
tc.constraint_type, \
|
||||
array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] as columns \
|
||||
FROM information_schema.table_constraints tc \
|
||||
JOIN information_schema.key_column_usage kcu \
|
||||
ON tc.constraint_name = kcu.constraint_name \
|
||||
AND tc.table_schema = kcu.table_schema \
|
||||
WHERE tc.table_schema = $1 AND tc.table_name = $2 \
|
||||
GROUP BY tc.constraint_name, tc.constraint_type \
|
||||
ORDER BY tc.constraint_type, tc.constraint_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.bind(&table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| ConstraintInfo {
|
||||
name: r.get::<String, _>(0),
|
||||
constraint_type: r.get::<String, _>(1),
|
||||
columns: r.get::<Vec<String>, _>(2),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_indexes(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> TuskResult<Vec<IndexInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT \
|
||||
i.relname as index_name, \
|
||||
pg_get_indexdef(i.oid) as index_def, \
|
||||
ix.indisunique as is_unique, \
|
||||
ix.indisprimary as is_primary \
|
||||
FROM pg_index ix \
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||
WHERE n.nspname = $1 AND t.relname = $2 \
|
||||
ORDER BY i.relname",
|
||||
)
|
||||
.bind(&schema)
|
||||
.bind(&table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| IndexInfo {
|
||||
name: r.get::<String, _>(0),
|
||||
definition: r.get::<String, _>(1),
|
||||
is_unique: r.get::<bool, _>(2),
|
||||
is_primary: r.get::<bool, _>(3),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
33
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TuskError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error("Connection not found: {0}")]
|
||||
ConnectionNotFound(String),
|
||||
|
||||
#[error("Not connected: {0}")]
|
||||
NotConnected(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl Serialize for TuskError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type TuskResult<T> = Result<T, TuskError>;
|
||||
46
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
mod commands;
|
||||
mod error;
|
||||
mod models;
|
||||
mod state;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// connections
|
||||
commands::connections::get_connections,
|
||||
commands::connections::save_connection,
|
||||
commands::connections::delete_connection,
|
||||
commands::connections::test_connection,
|
||||
commands::connections::connect,
|
||||
commands::connections::switch_database,
|
||||
commands::connections::disconnect,
|
||||
// queries
|
||||
commands::queries::execute_query,
|
||||
// schema
|
||||
commands::schema::list_databases,
|
||||
commands::schema::list_schemas,
|
||||
commands::schema::list_tables,
|
||||
commands::schema::list_views,
|
||||
commands::schema::list_functions,
|
||||
commands::schema::list_indexes,
|
||||
commands::schema::list_sequences,
|
||||
commands::schema::get_table_columns,
|
||||
commands::schema::get_table_constraints,
|
||||
commands::schema::get_table_indexes,
|
||||
// data
|
||||
commands::data::get_table_data,
|
||||
commands::data::update_row,
|
||||
commands::data::insert_row,
|
||||
commands::data::delete_rows,
|
||||
// export
|
||||
commands::export::export_csv,
|
||||
commands::export::export_json,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tusk_lib::run();
|
||||
}
|
||||
41
src-tauri/src/models/connection.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub database: String,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
impl ConnectionConfig {
|
||||
pub fn connection_url(&self) -> String {
|
||||
let ssl = self.ssl_mode.as_deref().unwrap_or("prefer");
|
||||
format!(
|
||||
"postgres://{}:{}@{}:{}/{}?sslmode={}",
|
||||
urlencoded(&self.user),
|
||||
urlencoded(&self.password),
|
||||
self.host,
|
||||
self.port,
|
||||
urlencoded(&self.database),
|
||||
ssl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')'
|
||||
| '*' | '+' | ',' | ';' | '=' | '%' | ' ' => {
|
||||
format!("%{:02X}", c as u8)
|
||||
}
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
3
src-tauri/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod connection;
|
||||
pub mod query_result;
|
||||
pub mod schema;
|
||||
23
src-tauri/src/models/query_result.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryResult {
|
||||
pub columns: Vec<String>,
|
||||
pub types: Vec<String>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
pub row_count: usize,
|
||||
pub execution_time_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaginatedQueryResult {
|
||||
pub columns: Vec<String>,
|
||||
pub types: Vec<String>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
pub row_count: usize,
|
||||
pub execution_time_ms: u128,
|
||||
pub total_rows: i64,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
}
|
||||
34
src-tauri/src/models/schema.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SchemaObject {
|
||||
pub name: String,
|
||||
pub object_type: String,
|
||||
pub schema: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
pub ordinal_position: i32,
|
||||
pub character_maximum_length: Option<i32>,
|
||||
pub is_primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConstraintInfo {
|
||||
pub name: String,
|
||||
pub constraint_type: String,
|
||||
pub columns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexInfo {
|
||||
pub name: String,
|
||||
pub definition: String,
|
||||
pub is_unique: bool,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
18
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct AppState {
|
||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||
pub config_path: RwLock<Option<PathBuf>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pools: RwLock::new(HashMap::new()),
|
||||
config_path: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Tusk",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.tusk.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Tusk",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||