fix(mongodb): guard aggregate execution paths
This commit is contained in:
parent
c56bcb8e2d
commit
5ea146e08e
|
|
@ -622,7 +622,7 @@ function ensureQueryTab(): string {
|
|||
const tab = activeTab.value;
|
||||
if (tab && tab.mode === "query") return tab.id;
|
||||
const connId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
const db = tab?.database || connectionStore.getConfig(connId)?.database || "";
|
||||
const db = tab?.connectionId === connId ? tab.database : connectionStore.getConfig(connId)?.database || "";
|
||||
return queryStore.createTab(connId, db, undefined, "query");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,9 +54,15 @@ export function useTauriEvents(deps: {
|
|||
}
|
||||
}).then((unlisten) => unlistenHandles.push(unlisten));
|
||||
|
||||
listen<{ connection_id: string; database: string; sql: string }>("mcp-execute-query", async (event) => {
|
||||
listen<{
|
||||
connection_id: string;
|
||||
database: string;
|
||||
sql: string;
|
||||
allow_writes?: boolean;
|
||||
allow_dangerous?: boolean;
|
||||
}>("mcp-execute-query", async (event) => {
|
||||
try {
|
||||
const { connection_id, database, sql } = event.payload;
|
||||
const { connection_id, database, sql, allow_writes, allow_dangerous } = event.payload;
|
||||
if (!connectionStore.connections.length) await connectionStore.initFromDisk();
|
||||
const config = connectionStore.getConfig(connection_id);
|
||||
if (!config) return;
|
||||
|
|
@ -64,7 +70,9 @@ export function useTauriEvents(deps: {
|
|||
await connectionStore.ensureConnected(connection_id);
|
||||
const tabId = queryStore.createTab(connection_id, database, undefined, "query");
|
||||
queryStore.updateSql(tabId, sql);
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
await queryStore.executeTabSql(tabId, sql, {
|
||||
mongoSafety: { allowWrites: !!allow_writes, allowDangerous: !!allow_dangerous },
|
||||
});
|
||||
focusCurrentWindow();
|
||||
} catch (e) {
|
||||
console.error("[DBX] mcp-execute-query error:", e);
|
||||
|
|
|
|||
|
|
@ -1272,8 +1272,9 @@ export async function mongoAggregateDocuments(
|
|||
database: string,
|
||||
collection: string,
|
||||
pipelineJson: string,
|
||||
maxRows?: number,
|
||||
): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/aggregate-documents", { connectionId, database, collection, pipelineJson });
|
||||
return post("/api/mongo/aggregate-documents", { connectionId, database, collection, pipelineJson, maxRows });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export interface MongoAggregateCommand {
|
|||
pipeline: string;
|
||||
}
|
||||
|
||||
export interface MongoAggregateSafetyOptions {
|
||||
allowWrites?: boolean;
|
||||
allowDangerous?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export function parseMongoFindCommand(input: string): MongoFindCommand | null {
|
||||
|
|
@ -102,6 +107,42 @@ export function parseMongoAggregateCommand(input: string): MongoAggregateCommand
|
|||
};
|
||||
}
|
||||
|
||||
export function mongoAggregateWriteStage(pipelineJson: string): "$out" | "$merge" | null {
|
||||
try {
|
||||
const pipeline = JSON.parse(pipelineJson);
|
||||
if (!Array.isArray(pipeline)) return null;
|
||||
for (const stage of pipeline) {
|
||||
if (!isRecord(stage)) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(stage, "$out")) return "$out";
|
||||
if (Object.prototype.hasOwnProperty.call(stage, "$merge")) return "$merge";
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function evaluateMongoAggregateSafety(
|
||||
command: MongoAggregateCommand,
|
||||
options: MongoAggregateSafetyOptions,
|
||||
): { allowed: boolean; reason?: string } {
|
||||
const writeStage = mongoAggregateWriteStage(command.pipeline);
|
||||
if (!writeStage) return { allowed: true };
|
||||
if (!options.allowWrites) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" writes data. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.`,
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `MongoDB aggregate stage "${writeStage}" is dangerous. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number): QueryResult {
|
||||
const columns: string[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1103,8 +1103,9 @@ export async function mongoAggregateDocuments(
|
|||
database: string,
|
||||
collection: string,
|
||||
pipelineJson: string,
|
||||
maxRows?: number,
|
||||
): Promise<MongoDocumentResult> {
|
||||
return invoke("mongo_aggregate_documents", { connectionId, database, collection, pipelineJson });
|
||||
return invoke("mongo_aggregate_documents", { connectionId, database, collection, pipelineJson, maxRows });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan";
|
|||
import { allEditableColumnsWriteable, allPrimaryKeysPresent, sourceColumnsForResult } from "@/lib/sqlAnalysis";
|
||||
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
|
||||
import {
|
||||
evaluateMongoAggregateSafety,
|
||||
mongoCountToQueryResult,
|
||||
mongoDocumentsToQueryResult,
|
||||
parseMongoAggregateCommand,
|
||||
parseMongoCountDocumentsCommand,
|
||||
parseMongoFindCommand,
|
||||
type MongoAggregateSafetyOptions,
|
||||
} from "@/lib/mongoShellCommand";
|
||||
import { AGENT_DRIVER_TYPES } from "@/lib/databaseCapabilities";
|
||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||
|
|
@ -528,6 +530,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
resultBaseSql?: string;
|
||||
resultSortedSql?: string | undefined;
|
||||
pagination?: { limit: number; offset: number; sessionId?: string };
|
||||
mongoSafety?: MongoAggregateSafetyOptions;
|
||||
},
|
||||
) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
|
|
@ -655,6 +658,10 @@ export const useQueryStore = defineStore("query", () => {
|
|||
|
||||
const mongoAggregate = conn?.db_type === "mongodb" ? parseMongoAggregateCommand(sql) : null;
|
||||
if (mongoAggregate) {
|
||||
if (options?.mongoSafety) {
|
||||
const safety = evaluateMongoAggregateSafety(mongoAggregate, options.mongoSafety);
|
||||
if (!safety.allowed) throw new Error(safety.reason);
|
||||
}
|
||||
await connStore.ensureConnected(tab.connectionId);
|
||||
console.info("[DBX][executeTabSql:mongo-aggregate:start]", { traceId, collection: mongoAggregate.collection });
|
||||
const result = await api.mongoAggregateDocuments(
|
||||
|
|
@ -662,6 +669,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.database,
|
||||
mongoAggregate.collection,
|
||||
mongoAggregate.pipeline,
|
||||
pageLimit,
|
||||
);
|
||||
console.info("[DBX][executeTabSql:mongo-aggregate:done]", {
|
||||
traceId,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ pub async fn aggregate_documents(
|
|||
database: &str,
|
||||
collection: &str,
|
||||
pipeline_json: &str,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(pipeline_json).map_err(|e| format!("Invalid pipeline JSON: {e}"))?;
|
||||
|
|
@ -93,12 +94,17 @@ pub async fn aggregate_documents(
|
|||
.collect::<Result<Vec<Document>, String>>()?;
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let mut cursor = col.aggregate(pipeline).await.map_err(|e| e.to_string())?;
|
||||
let max_rows = max_rows.unwrap_or(100);
|
||||
let fetch_limit = max_rows.saturating_add(1);
|
||||
let mut documents = Vec::new();
|
||||
while cursor.advance().await.map_err(|e| e.to_string())? {
|
||||
while documents.len() < fetch_limit && cursor.advance().await.map_err(|e| e.to_string())? {
|
||||
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
|
||||
documents.push(bson_to_json(&Bson::Document(doc)));
|
||||
}
|
||||
let total = documents.len() as u64;
|
||||
if documents.len() > max_rows {
|
||||
documents.truncate(max_rows);
|
||||
}
|
||||
Ok(MongoDocumentResult { documents, total })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,11 +77,12 @@ pub async fn mongo_aggregate_documents_core(
|
|||
database: &str,
|
||||
collection: &str,
|
||||
pipeline_json: &str,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
mongo_driver::aggregate_documents(client, database, collection, pipeline_json).await
|
||||
mongo_driver::aggregate_documents(client, database, collection, pipeline_json, max_rows).await
|
||||
}
|
||||
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support aggregate".to_string()),
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
|
|
|
|||
|
|
@ -399,14 +399,7 @@ pub async fn list_tables_core(
|
|||
}
|
||||
|
||||
fn collection_names_to_tables(names: Vec<String>, table_type: &str) -> Vec<db::TableInfo> {
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| db::TableInfo {
|
||||
name,
|
||||
table_type: table_type.to_string(),
|
||||
comment: None,
|
||||
})
|
||||
.collect()
|
||||
names.into_iter().map(|name| db::TableInfo { name, table_type: table_type.to_string(), comment: None }).collect()
|
||||
}
|
||||
|
||||
fn filter_table_infos(tables: Vec<db::TableInfo>, filter: Option<&str>, limit: Option<usize>) -> Vec<db::TableInfo> {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub struct MongoAggregateRequest {
|
|||
pub database: String,
|
||||
pub collection: String,
|
||||
pub pipeline_json: String,
|
||||
pub max_rows: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -147,6 +148,7 @@ pub async fn aggregate_documents(
|
|||
&req.database,
|
||||
&req.collection,
|
||||
&req.pipeline_json,
|
||||
req.max_rows,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
evaluateMongoAggregateSafety,
|
||||
mongoAggregateWriteStage,
|
||||
mongoCountToQueryResult,
|
||||
mongoDocumentsToQueryResult,
|
||||
parseMongoAggregateCommand,
|
||||
|
|
@ -76,6 +78,19 @@ test("parseMongoAggregateCommand normalises ObjectId arguments with either quote
|
|||
}
|
||||
});
|
||||
|
||||
test("evaluateMongoAggregateSafety blocks write stages unless MCP write flags allow them", () => {
|
||||
const out = parseMongoAggregateCommand('db.products.aggregate([{"$out":"products_copy"}])');
|
||||
assert.ok(out);
|
||||
assert.equal(mongoAggregateWriteStage(out.pipeline), "$out");
|
||||
assert.match(evaluateMongoAggregateSafety(out, {}).reason || "", /DBX_MCP_ALLOW_WRITES=1/);
|
||||
|
||||
const merge = parseMongoAggregateCommand('db.products.aggregate([{"$merge":{"into":"products_copy"}}])');
|
||||
assert.ok(merge);
|
||||
assert.equal(mongoAggregateWriteStage(merge.pipeline), "$merge");
|
||||
assert.match(evaluateMongoAggregateSafety(merge, { allowWrites: true }).reason || "", /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
assert.equal(evaluateMongoAggregateSafety(merge, { allowWrites: true, allowDangerous: true }).allowed, true);
|
||||
});
|
||||
|
||||
test("mongoCountToQueryResult returns a single count row", () => {
|
||||
assert.deepEqual(mongoCountToQueryResult(42, 5), {
|
||||
columns: ["count"],
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { z } from "zod";
|
|||
import {
|
||||
buildSchemaContext,
|
||||
createBackend,
|
||||
evaluateMongoAggregateSafety,
|
||||
evaluateSqlSafety,
|
||||
formatSchemaContext,
|
||||
notifyReload,
|
||||
parseMongoAggregateCommand,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
type Backend,
|
||||
|
|
@ -216,13 +218,24 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
},
|
||||
async ({ connection_name, sql, database }) => {
|
||||
const config = await backend.findConnection(connection_name);
|
||||
if (config?.db_type !== "mongodb") {
|
||||
const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv());
|
||||
const safetyOptions = sqlSafetyFromEnv();
|
||||
if (config?.db_type === "mongodb") {
|
||||
const aggregate = parseMongoAggregateCommand(sql);
|
||||
if (aggregate) {
|
||||
const safety = evaluateMongoAggregateSafety(aggregate, safetyOptions);
|
||||
if (!safety.allowed) return text(`Query blocked: ${safety.reason}`);
|
||||
}
|
||||
} else {
|
||||
const safety = evaluateSqlSafety(sql, safetyOptions);
|
||||
if (!safety.allowed) return text(`Query blocked: ${safety.reason}`);
|
||||
}
|
||||
// MongoDB shell commands bypass the SQL safety evaluator; the desktop
|
||||
// app's executor applies command-aware read/write gating.
|
||||
return bridgeRequest("/execute-query", { connection_name, sql, database }, "Query sent to DBX");
|
||||
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
|
||||
// safety flags to the desktop executor for command-aware gating.
|
||||
return bridgeRequest(
|
||||
"/execute-query",
|
||||
{ connection_name, sql, database, allow_writes: safetyOptions.allowWrites, allow_dangerous: safetyOptions.allowDangerous },
|
||||
"Query sent to DBX",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,3 +117,32 @@ test("mongodb execute query formats shell-style find results", async () => {
|
|||
assert.match(result.content[0].text, /demo/);
|
||||
assert.match(result.content[0].text, /1 row\(s\)/);
|
||||
});
|
||||
|
||||
test("mongodb execute-and-show blocks aggregate write stages before desktop bridge", async () => {
|
||||
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
|
||||
const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
||||
delete process.env.DBX_MCP_ALLOW_WRITES;
|
||||
delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
||||
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
|
||||
const scopedBackend: Backend = {
|
||||
...backend,
|
||||
findConnection: async () => mongoConnection,
|
||||
};
|
||||
const server = createDbxMcpServer(scopedBackend, { isWebMode: false });
|
||||
|
||||
try {
|
||||
const result = await (server as any)._registeredTools.dbx_execute_and_show.handler({
|
||||
connection_name: "local",
|
||||
database: "pystrument",
|
||||
sql: 'db.projects.aggregate([{"$out":"projects_dump"}])',
|
||||
});
|
||||
|
||||
assert.match(result.content[0].text, /Query blocked:/);
|
||||
assert.match(result.content[0].text, /DBX_MCP_ALLOW_WRITES=1/);
|
||||
} finally {
|
||||
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
|
||||
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
||||
if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
||||
else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -427,7 +427,10 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
|
|||
if (aggregate) {
|
||||
const safety = evaluateMongoAggregateSafety(aggregate, sqlSafetyFromEnv());
|
||||
if (!safety.allowed) throw new Error(safety.reason);
|
||||
const result = await withTimeout(mongoAggregateDocuments(config, aggregate.collection, aggregate.pipeline), resolveTimeoutMs(options));
|
||||
const result = await withTimeout(
|
||||
mongoAggregateDocuments(config, aggregate.collection, aggregate.pipeline, resolveMaxRows(options)),
|
||||
resolveTimeoutMs(options),
|
||||
);
|
||||
return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total);
|
||||
}
|
||||
const write = parseMongoWriteCommand(sql);
|
||||
|
|
@ -599,12 +602,14 @@ async function mongoAggregateDocuments(
|
|||
config: ConnectionConfig,
|
||||
collection: string,
|
||||
pipelineJson: string,
|
||||
maxRows: number,
|
||||
): Promise<MongoDocumentResult> {
|
||||
return bridgeDataRequest<MongoDocumentResult>("/data/mongo/aggregate-documents", {
|
||||
connection_name: config.name,
|
||||
database: config.database || "",
|
||||
collection,
|
||||
pipeline_json: pipelineJson,
|
||||
max_rows: maxRows,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
|
|||
database: config.database || "",
|
||||
collection: aggregate.collection,
|
||||
pipelineJson: aggregate.pipeline,
|
||||
maxRows: options?.maxRows ?? 100,
|
||||
}),
|
||||
});
|
||||
const result = (await res.json()) as { documents: unknown[]; total: number };
|
||||
|
|
@ -260,4 +261,3 @@ async function executeMongoWrite(config: ConnectionConfig, command: MongoWriteCo
|
|||
const result = (await res.json()) as { affected_rows: number };
|
||||
return result.affected_rows;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ mod tests {
|
|||
ssh_key_passphrase: String::new(),
|
||||
ssh_expose_lan: false,
|
||||
ssh_connect_timeout_secs: dbx_core::models::connection::default_ssh_connect_timeout_secs(),
|
||||
connect_timeout_secs: dbx_core::models::connection::default_connect_timeout_secs(),
|
||||
query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(),
|
||||
proxy_enabled: false,
|
||||
proxy_type: ProxyType::Socks5,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ struct ExecuteQueryRequest {
|
|||
database: Option<String>,
|
||||
sql: String,
|
||||
schema: Option<String>,
|
||||
allow_writes: Option<bool>,
|
||||
allow_dangerous: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -56,6 +58,7 @@ struct MongoAggregateDocumentsRequest {
|
|||
database: Option<String>,
|
||||
collection: String,
|
||||
pipeline_json: String,
|
||||
max_rows: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -98,6 +101,8 @@ pub struct McpExecuteQueryEvent {
|
|||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub sql: String,
|
||||
pub allow_writes: bool,
|
||||
pub allow_dangerous: bool,
|
||||
}
|
||||
|
||||
pub fn start(app_handle: AppHandle, state: Arc<AppState>) {
|
||||
|
|
@ -282,6 +287,8 @@ async fn handle_execute_query(app: &AppHandle, state: &Arc<AppState>, body: &str
|
|||
connection_id: config.id.clone(),
|
||||
database: req.database.unwrap_or_else(|| config.database.clone().unwrap_or_default()),
|
||||
sql: req.sql,
|
||||
allow_writes: req.allow_writes.unwrap_or(false),
|
||||
allow_dangerous: req.allow_dangerous.unwrap_or(false),
|
||||
};
|
||||
let _ = app.emit("mcp-execute-query", &event);
|
||||
respond(stream, "200 OK", "ok").await;
|
||||
|
|
@ -398,6 +405,7 @@ async fn handle_mongo_aggregate_documents_data(state: &Arc<AppState>, body: &str
|
|||
&database,
|
||||
&req.collection,
|
||||
&req.pipeline_json,
|
||||
req.max_rows,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,9 +52,17 @@ pub async fn mongo_aggregate_documents(
|
|||
database: String,
|
||||
collection: String,
|
||||
pipeline_json: String,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
dbx_core::mongo_ops::mongo_aggregate_documents_core(&state, &connection_id, &database, &collection, &pipeline_json)
|
||||
.await
|
||||
dbx_core::mongo_ops::mongo_aggregate_documents_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&collection,
|
||||
&pipeline_json,
|
||||
max_rows,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ use commands::connection::AppState;
|
|||
use dbx_core::storage::Storage;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::Manager;
|
||||
use tauri::{
|
||||
menu::MenuBuilder,
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
use tauri::Manager;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::{Emitter, RunEvent};
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
|
|
|
|||
Loading…
Reference in New Issue