Compare commits

...
4 Commits
56 changed files with 1688 additions and 1218 deletions
Generated
+74 -48
View File
@@ -40,18 +40,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "0.6.10"
@@ -1066,10 +1054,11 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.26"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -2417,6 +2406,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fixedbitset"
version = "0.4.2"
@@ -2849,8 +2844,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -3091,7 +3088,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
"ahash",
]
[[package]]
@@ -3099,9 +3096,6 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
@@ -3131,11 +3125,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
dependencies = [
"hashbrown 0.14.5",
"hashbrown 0.16.1",
]
[[package]]
@@ -3561,17 +3555,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inherent"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "inotify"
version = "0.11.0"
@@ -4007,9 +3990,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"pkg-config",
@@ -6242,9 +6225,9 @@ dependencies = [
[[package]]
name = "r2d2_sqlite"
version = "0.25.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846"
checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6"
dependencies = [
"r2d2",
"rusqlite",
@@ -7130,10 +7113,20 @@ dependencies = [
]
[[package]]
name = "rusqlite"
version = "0.32.1"
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.17",
]
[[package]]
name = "rusqlite"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
dependencies = [
"bitflags 2.11.0",
"chrono",
@@ -7142,6 +7135,7 @@ dependencies = [
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
@@ -7423,20 +7417,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sea-query"
version = "0.32.6"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a"
checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31"
dependencies = [
"chrono",
"inherent",
"itoa",
"sea-query-derive",
]
[[package]]
name = "sea-query-derive"
version = "0.4.3"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
dependencies = [
"darling 0.20.11",
"heck 0.4.1",
@@ -7448,9 +7442,9 @@ dependencies = [
[[package]]
name = "sea-query-rusqlite"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3743bbdfb24b1a84cc1a6fbf4b1188e6851f6e00ea20944b44c56bf03a585bb4"
checksum = "1ec6038023c8517c623e5bf9606b3c54d40bc8296bb6b2986040428dd84deddd"
dependencies = [
"rusqlite",
"sea-query",
@@ -7861,9 +7855,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
@@ -8031,6 +8025,18 @@ dependencies = [
"system-deps",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -11035,6 +11041,7 @@ dependencies = [
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-rpc-schema",
"yaak-sse",
"yaak-sync",
"yaak-system-appearance",
@@ -11135,6 +11142,7 @@ name = "yaak-database"
version = "0.1.0"
dependencies = [
"chrono",
"getrandom 0.2.16",
"include_dir",
"log 0.4.29",
"nanoid",
@@ -11147,6 +11155,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"ts-rs",
"uuid",
]
[[package]]
@@ -11372,6 +11381,22 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "yaak-rpc-schema"
version = "0.0.0"
dependencies = [
"serde",
"ts-rs",
"yaak-git",
"yaak-grpc",
"yaak-models",
"yaak-plugins",
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-ws",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11437,6 +11462,7 @@ version = "0.1.0"
dependencies = [
"log 0.4.29",
"p12",
"pem",
"rustls",
"rustls-pemfile",
"rustls-platform-verifier",
+2
View File
@@ -5,6 +5,7 @@ members = [
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
"crates/common/yaak-rpc-schema",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -63,6 +64,7 @@ ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RpcPayload } from "@yaakapp-internal/platform";
import { platform } from "@yaakapp-internal/platform";
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
/**
* Every backend command the app can call: the generated wire schema, one field
+1 -1
View File
@@ -1,5 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
import type { EphemeralHttpResponse } from "@yaakapp-internal/rpc-schema";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { rpc } from "./rpc";
+3 -3
View File
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
log = { workspace = true }
include_dir = "0.7"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
r2d2_sqlite = "0.32"
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl"] }
+2 -1
View File
@@ -39,7 +39,7 @@ md5 = "0.8.0"
notify = "8.0.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
r2d2_sqlite = "0.32"
mime_guess = "2.0.5"
rand = "0.9.0"
reqwest = { workspace = true, features = [
@@ -73,6 +73,7 @@ url = "2"
tokio-util = { version = "0.7", features = ["codec"] }
ts-rs = { workspace = true }
yaak-rpc = { workspace = true }
yaak-rpc-schema = { workspace = true }
uuid = "1.12.1"
yaak-api = { workspace = true }
yaak-common = { workspace = true }
-4
View File
@@ -1,7 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitWatchResult = { unlistenEvent: string, };
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
@@ -12,8 +10,6 @@ export type UpdateResponse = { "type": "ack" } | { "type": "action", action: Upd
export type UpdateResponseAction = "install" | "skip";
export type WatchResult = { unlistenEvent: string, };
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotificationAction = { label: string, url: string, };
+3 -3
View File
@@ -1,4 +1,4 @@
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
// entry point is where the generated files come together.
export * from "./bindings/gen_rpc";
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
// desktop-only surface: updater and notification types.
export * from "./bindings/index";
@@ -2,7 +2,6 @@ use crate::error::{Error, Result};
use chrono::Utc;
use log::{debug, error, warn};
use notify::Watcher;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
@@ -10,18 +9,11 @@ use tauri::{AppHandle, Listener, Runtime};
use tokio::select;
use tokio::sync::watch;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
use yaak_rpc_schema::GitWatchResult;
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub(crate) struct GitWatchResult {
unlisten_event: String,
}
pub(crate) async fn watch_git_worktree_status<R, F>(
app_handle: AppHandle<R>,
dir: &Path,
+1 -17
View File
@@ -8,7 +8,6 @@ use crate::import::{import_data, import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template};
use crate::rpc_ext::EphemeralHttpResponse;
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
@@ -57,6 +56,7 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
use yaak_sse::sse::ServerSentEvent;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::format_json::format_json;
@@ -184,22 +184,6 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
}
}
#[derive(serde::Serialize, ts_rs::TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct AppMetaData {
is_dev: bool,
version: String,
cli_version: Option<String>,
name: String,
app_data_dir: String,
app_log_dir: String,
vendored_plugin_dir: String,
default_project_dir: String,
feature_updater: bool,
feature_license: bool,
}
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
let app_data_dir = app_handle.path().app_data_dir()?;
let app_log_dir = app_handle.path().app_log_dir()?;
+3 -56
View File
@@ -144,28 +144,10 @@ pub(crate) fn models_upsert<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
let db = window.db();
let blobs = window.blob_manager();
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
};
Ok(id)
yaak::models_ops::upsert_model(&db, &blobs, model, source)
}
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
@@ -181,21 +163,7 @@ pub(crate) async fn models_delete<R: Runtime>(
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
yaak::models_ops::delete_model(tx, &blobs, model, source)
})
})
.await
@@ -207,31 +175,10 @@ pub(crate) fn models_duplicate<R: Runtime>(
model_type: String,
model_id: String,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
// Use transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
// Fetch the model fresh from the DB so the duplicate doesn't come from
// a stale frontend snapshot
let id = match model_type.as_str() {
"environment" => {
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
}
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
"grpc_request" => {
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
}
"http_request" => {
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
}
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
})
}
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -6,11 +6,10 @@ use crate::error::Result;
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use chrono::Utc;
use log::warn;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::{AppHandle, Listener, Runtime};
use tokio::sync::watch;
use ts_rs::TS;
use yaak_rpc_schema::WatchResult;
use yaak_sync::error::Error::InvalidSyncDirectory;
use yaak_sync::sync::{
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
@@ -57,13 +56,6 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub(crate) struct WatchResult {
unlisten_event: String,
}
pub(crate) async fn sync_watch<R, F>(
app_handle: AppHandle<R>,
sync_dir: &Path,
+13 -5
View File
@@ -9,12 +9,20 @@ chrono = { version = "0.4.38", features = ["serde"] }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.25.0" }
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
# nanoid pulls getrandom, which needs to be told how to reach the browser's
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
uuid = { version = "1", features = ["js"] }
@@ -1,9 +1,8 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use crate::pool::SqliteConn;
use rusqlite::{Connection, Statement, ToSql, Transaction};
pub enum ConnectionOrTx<'a> {
Connection(PooledConnection<SqliteConnectionManager>),
Connection(SqliteConn),
Transaction(&'a Transaction<'a>),
}
@@ -3,6 +3,7 @@ use crate::error::Error::ModelNotFound;
use crate::error::Result;
use crate::traits::UpsertModelInfo;
use crate::update_source::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
+1 -1
View File
@@ -7,7 +7,7 @@ pub enum Error {
SqlError(#[from] rusqlite::Error),
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] r2d2::Error),
SqlPoolError(#[from] crate::pool::PoolError),
#[error("Database error: {0}")]
Database(String),
+5 -2
View File
@@ -2,6 +2,7 @@ pub mod connection_or_tx;
pub mod db_context;
pub mod error;
pub mod migrate;
pub mod pool;
pub mod traits;
pub mod update_source;
pub mod util;
@@ -11,13 +12,15 @@ pub use connection_or_tx::ConnectionOrTx;
pub use db_context::DbContext;
pub use error::{Error, Result};
pub use migrate::run_migrations;
pub use pool::{PoolError, SqliteConn, SqlitePool};
pub use traits::{UpsertModelInfo, upsert_date};
pub use update_source::{ModelChangeEvent, UpdateSource};
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
// Re-export pool types that consumers will need
// Re-export types that consumers will need
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2;
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2_sqlite;
pub use rusqlite;
pub use sea_query;
pub use sea_query_rusqlite;
+2 -3
View File
@@ -1,8 +1,7 @@
use crate::error::Result;
use crate::pool::SqlitePool;
use include_dir::Dir;
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params};
const TRACKING_TABLE: &str = "_sqlx_migrations";
@@ -11,7 +10,7 @@ const TRACKING_TABLE: &str = "_sqlx_migrations";
///
/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`).
/// Applied migrations are tracked in `_sqlx_migrations`.
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
pub fn run_migrations(pool: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
info!("Running migrations");
// Create tracking table
+80
View File
@@ -0,0 +1,80 @@
//! Where connections come from.
//!
//! Every query in the model layer asks a pool for a connection, uses it, and
//! hands it back. That is the whole contract, and it is the one place the
//! desktop and the browser genuinely differ: the desktop has threads and wants
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
//! connection is exactly enough. Everything above this module is identical on
//! both.
//!
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
//! that already builds pools changes. On wasm it is one connection that every
//! `get()` hands out a shared handle to.
//!
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
//! compile-time guard against nesting a transaction on one connection, and it
//! is what would have forced the wasm pool to lend its connection exclusively.
//! The model layer nests connections freely — a helper that already holds one
//! calls another that asks for its own — so an exclusive lend would panic on
//! the second ask. Sharing the handle instead makes nested *reads* work the way
//! they do on the desktop; nested *write transactions* fail on both, only
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
//! connection blocks on `busy_timeout` and then fails).
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use r2d2_sqlite::SqliteConnectionManager;
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
pub type PoolError = r2d2::Error;
}
#[cfg(target_arch = "wasm32")]
mod imp {
use rusqlite::Connection;
use std::ops::Deref;
use std::rc::Rc;
/// One connection, shared by everyone who asks.
///
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
/// thread here to be honest about.
#[derive(Clone, Debug)]
pub struct SqlitePool {
conn: Rc<Connection>,
}
impl SqlitePool {
pub fn single(conn: Connection) -> Self {
Self { conn: Rc::new(conn) }
}
/// Another handle to the connection. Cannot fail; the `Result` keeps
/// the signature identical to r2d2's so callers are written once.
pub fn get(&self) -> Result<SqliteConn, PoolError> {
Ok(SqliteConn(self.conn.clone()))
}
}
/// The error a `get()` would return if it could. It can't, so this has no
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
/// targets.
#[derive(Debug, thiserror::Error)]
pub enum PoolError {}
#[derive(Debug)]
pub struct SqliteConn(Rc<Connection>);
impl Deref for SqliteConn {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.0
}
}
}
pub use imp::*;
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "yaak-rpc-schema"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
serde = { workspace = true, features = ["derive"] }
ts-rs = { workspace = true }
yaak-git = { workspace = true }
yaak-grpc = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-ws = { workspace = true }
+44
View File
@@ -0,0 +1,44 @@
# yaak-rpc-schema
The wire schema for the app's RPC surface: every command name, its request
payload, and its response type, declared once.
Every host that serves the Yaak UI — the desktop app today, the browser bridge
and anything after it — imports these types and implements the commands against
them. That is what keeps a request's shape from drifting between hosts, and it
is why the TypeScript bindings (`bindings/gen_rpc.ts`, exposed to the frontend
as `@yaakapp-internal/rpc-schema`) are generated from one place.
Nothing here depends on Tauri or on any host. Request structs are plain data,
and so are the few response types declared here rather than in an engine crate.
Command *bodies* live with the host that runs them.
## Adding a command
1. Add its request struct and an entry in `with_commands!` in `src/lib.rs`.
2. Write the adapter in each host — the desktop's live in
`crates-tauri/yaak-app-client/src/rpc_ext.rs`. A host that does not support
the command still has to say so; a missing adapter fails to compile.
3. Regenerate the bindings: `cargo test -p yaak-rpc-schema` writes
`bindings/gen_rpc.ts`, which is committed.
## How hosts consume the list
`with_commands!` takes the name of a `macro_rules!` macro and calls it with the
full `name(Req) -> Res` list. Each host writes a small macro that receives that
list and builds its router:
```rust
macro_rules! register_commands {
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
pub fn build_router() -> RpcRouter<MyCtx> {
let mut router = RpcRouter::new();
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
router
}
};
}
yaak_rpc_schema::with_commands!(register_commands);
```
The schema decides *what* commands exist; the host decides *how* each one runs.
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
// The RPC wire schema, generated by ts-rs from the Rust declarations in
// src/lib.rs. `RpcSchema` maps every command name to its (request, response)
// pair; the app's `rpc()` helper derives its command union from it.
export * from "./bindings/gen_rpc";
@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/rpc-schema",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -4,7 +4,9 @@ use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
use std::sync::{Arc, Mutex};
use yaak_models::models::DnsOverride;
use yaak_tls::{ClientCertificateConfig, get_tls_config, load_client_identity_pkcs12};
use yaak_tls::{
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
};
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
@@ -61,12 +63,19 @@ static IDENTITY_IMPORT: Mutex<()> = Mutex::new(());
fn build_native_tls_identity(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<native_tls::Identity>> {
let Some((pkcs12, password)) = load_client_identity_pkcs12(client_cert)? else {
let Some(material) = load_native_client_identity(client_cert)? else {
return Ok(None);
};
let _guard = IDENTITY_IMPORT.lock().unwrap_or_else(|e| e.into_inner());
Ok(Some(native_tls::Identity::from_pkcs12(&pkcs12, &password)?))
Ok(Some(match material {
NativeClientIdentity::Pkcs12 { data, password } => {
native_tls::Identity::from_pkcs12(&data, &password)?
}
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => {
native_tls::Identity::from_pkcs8(&chain_pem, &key_pem)?
}
}))
}
#[derive(Clone)]
+7 -5
View File
@@ -11,11 +11,9 @@ hex = { workspace = true }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.25.0" }
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
schemars = { workspace = true }
@@ -23,3 +21,7 @@ sha2 = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
+8 -9
View File
@@ -2,9 +2,8 @@ use crate::error::Result;
use crate::util::generate_prefixed_id;
use include_dir::{Dir, include_dir};
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params};
use yaak_database::{SqliteConn, SqlitePool};
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
@@ -29,11 +28,11 @@ impl BodyChunk {
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct BlobManager {
pool: Pool<SqliteConnectionManager>,
pool: SqlitePool,
}
impl BlobManager {
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
@@ -45,7 +44,7 @@ impl BlobManager {
/// Context for blob database operations.
pub struct BlobContext {
conn: r2d2::PooledConnection<SqliteConnectionManager>,
conn: SqliteConn,
}
impl BlobContext {
@@ -131,7 +130,7 @@ impl BlobContext {
}
/// Run migrations for the blob database.
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
info!("Running blob database migrations");
// Create migrations tracking table
@@ -198,9 +197,9 @@ pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
mod tests {
use super::*;
fn create_test_pool() -> Pool<SqliteConnectionManager> {
let manager = SqliteConnectionManager::memory();
let pool = Pool::builder().max_size(1).build(manager).unwrap();
fn create_test_pool() -> SqlitePool {
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
migrate_blob_db(&pool).unwrap();
pool
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub enum Error {
SqlError(#[from] rusqlite::Error),
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] r2d2::Error),
SqlPoolError(#[from] yaak_database::PoolError),
#[error("Database error: {0}")]
Database(String),
+77 -55
View File
@@ -1,15 +1,12 @@
use crate::blob_manager::{BlobManager, migrate_blob_db};
use crate::error::{Error, Result};
use crate::error::Result;
use crate::migrate::migrate_db;
use crate::query_manager::QueryManager;
use crate::util::ModelPayload;
use log::info;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::fs::create_dir_all;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
@@ -22,17 +19,78 @@ pub mod query_manager;
pub mod render;
pub mod util;
fn sqlite_file_manager(path: impl Into<PathBuf>) -> SqliteConnectionManager {
SqliteConnectionManager::file(path.into()).with_init(|conn| {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.busy_timeout(Duration::from_millis(5000))
})
/// Per-connection setup, applied by every pool on every connection it opens.
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.busy_timeout(std::time::Duration::from_millis(5000))
}
fn sqlite_memory_manager() -> SqliteConnectionManager {
SqliteConnectionManager::memory()
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)))
fn init_file_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
init_connection(conn)
}
/// The two ways a pool comes to exist, one per target.
///
/// On the desktop and CLI, an r2d2 pool over a file. In a browser, a single
/// connection over whatever VFS the host registered before calling in — the
/// path is a name inside that VFS, not a place on disk. Everything downstream
/// of `SqlitePool` is target-agnostic; this is the only fork.
#[cfg(not(target_arch = "wasm32"))]
mod open {
use super::*;
use crate::error::Error;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::PathBuf;
use std::time::Duration;
pub fn file_pool(path: impl Into<PathBuf>, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
let path: PathBuf = path.into();
// Create parent directories if needed
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
Pool::builder()
.max_size(max_size)
.min_idle(Some(min_idle))
.connection_timeout(Duration::from_secs(10))
.build(manager)
.map_err(|e| Error::Database(e.to_string()))
}
pub fn memory_pool() -> Result<SqlitePool> {
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
// In-memory DB doesn't support multiple connections
Pool::builder().max_size(1).build(manager).map_err(|e| Error::Database(e.to_string()))
}
}
#[cfg(target_arch = "wasm32")]
mod open {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;
pub fn file_pool(
path: impl Into<PathBuf>,
_max_size: u32,
_min_idle: u32,
) -> Result<SqlitePool> {
// No WAL: the browser VFSs are single-connection and journal their own
// way; the pragma is accepted and ignored on some and rejected on
// others, so it is not applied at all here.
let conn = Connection::open(path.into())?;
init_connection(&conn)?;
Ok(SqlitePool::single(conn))
}
pub fn memory_pool() -> Result<SqlitePool> {
let conn = Connection::open_in_memory()?;
init_connection(&conn)?;
Ok(SqlitePool::single(conn))
}
}
/// Initialize the database managers for standalone (non-Tauri) usage.
@@ -46,40 +104,16 @@ pub fn init_standalone(
let db_path = db_path.as_ref();
let blob_path = blob_path.as_ref();
// Create parent directories if needed
if let Some(parent) = db_path.parent() {
create_dir_all(parent)?;
}
if let Some(parent) = blob_path.parent() {
create_dir_all(parent)?;
}
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
// features — connections are held per-statement, so even heavy fan-out (e.g. many
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
info!("Initializing app database {db_path:?}");
let manager = sqlite_file_manager(db_path);
let pool = Pool::builder()
.max_size(20)
.min_idle(Some(2))
.connection_timeout(Duration::from_secs(10))
.build(manager)
.map_err(|e| Error::Database(e.to_string()))?;
let pool = open::file_pool(db_path, 20, 2)?;
migrate_db(&pool)?;
info!("Initializing blobs database {blob_path:?}");
// Blob database pool
let blob_manager = sqlite_file_manager(blob_path);
let blob_pool = Pool::builder()
.max_size(10)
.min_idle(Some(1))
.connection_timeout(Duration::from_secs(10))
.build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?;
let blob_pool = open::file_pool(blob_path, 10, 1)?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
@@ -92,22 +126,10 @@ pub fn init_standalone(
/// Initialize the database managers with in-memory SQLite databases.
/// Useful for testing and CI environments.
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
// Main database pool
let manager = sqlite_memory_manager();
let pool = Pool::builder()
.max_size(1) // In-memory DB doesn't support multiple connections
.build(manager)
.map_err(|e| Error::Database(e.to_string()))?;
let pool = open::memory_pool()?;
migrate_db(&pool)?;
// Blob database pool
let blob_manager = sqlite_memory_manager();
let blob_pool = Pool::builder()
.max_size(1)
.build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?;
let blob_pool = open::memory_pool()?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
+24 -8
View File
@@ -2,14 +2,13 @@ use crate::error::Error::MigrationError;
use crate::error::Result;
use include_dir::{Dir, DirEntry, include_dir};
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
use sha2::{Digest, Sha384};
use yaak_database::SqlitePool;
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
info!("Running database migrations");
// Ensure the table exists
@@ -43,8 +42,10 @@ pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
let mut ran_migrations = 0;
for entry in entries {
num_migrations += 1;
let mut conn = pool.get()?;
let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let conn = pool.get()?;
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let mut tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
match run_migration(entry, &mut tx) {
Ok(ran) => {
if ran {
@@ -74,7 +75,7 @@ pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
}
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
let start = std::time::Instant::now();
let start = elapsed_timer();
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
.expect("Failed to parse migration filename");
@@ -97,7 +98,7 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
// Split on `;`? → optional depending on how your SQL is structured
tx.execute_batch(&sql)?;
let execution_time = start.elapsed().as_nanos() as i64;
let execution_time = start();
let checksum = sha384_hex_prefixed(sql.as_bytes());
// NOTE: The success column is never used. It's just there for sqlx compatibility.
@@ -109,6 +110,21 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
Ok(true)
}
/// Nanoseconds since the timer was started, for the sqlx-compatible
/// `execution_time` column. `Instant` does not exist on `wasm32-unknown-unknown`
/// (there is no monotonic clock to ask), and the column is bookkeeping, so
/// there it reads as zero rather than taking the migrator down with it.
#[cfg(not(target_arch = "wasm32"))]
fn elapsed_timer() -> impl Fn() -> i64 {
let start = std::time::Instant::now();
move || start.elapsed().as_nanos() as i64
}
#[cfg(target_arch = "wasm32")]
fn elapsed_timer() -> impl Fn() -> i64 {
|| 0
}
fn split_migration_filename(filename: &str) -> Option<(String, String)> {
// Remove the .sql extension
let trimmed = filename.strip_suffix(".sql")?;
@@ -3,6 +3,7 @@ use crate::error::Result;
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
use crate::util::UpdateSource;
use chrono::{Duration, Utc};
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -4,6 +4,7 @@ use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -5,6 +5,7 @@ use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::{debug, error};
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::fs;
@@ -4,6 +4,7 @@ use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
use crate::util::UpdateSource;
use chrono::NaiveDateTime;
use log::error;
use sea_query::ExprTrait;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -1,6 +1,7 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{PluginKeyValue, PluginKeyValueIden};
use sea_query::ExprTrait;
use sea_query::Keyword::CurrentTimestamp;
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -2,6 +2,7 @@ use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
use crate::util::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::path::Path;
@@ -4,6 +4,7 @@ use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConne
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
+8 -9
View File
@@ -1,23 +1,21 @@
use crate::client_db::ClientDb;
use crate::error::Error::GenericError;
use crate::util::ModelPayload;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior;
use rusqlite::{Transaction, TransactionBehavior};
use std::sync::mpsc;
use yaak_database::{ConnectionOrTx, DbContext};
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct QueryManager {
pool: Pool<SqliteConnectionManager>,
pool: SqlitePool,
events_tx: mpsc::Sender<ModelPayload>,
}
impl QueryManager {
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
QueryManager { pool, events_tx }
}
@@ -46,9 +44,10 @@ impl QueryManager {
where
E: From<crate::error::Error>,
{
let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.expect("Failed to start DB transaction");
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
+1 -1
View File
@@ -1,5 +1,5 @@
import { platform } from "@yaakapp-internal/platform";
import type { WatchResult } from "@yaakapp-internal/tauri-client";
import type { WatchResult } from "@yaakapp-internal/rpc-schema";
import { SyncOp } from "./bindings/gen_sync";
import { WatchEvent } from "./bindings/gen_watch";
+1
View File
@@ -7,6 +7,7 @@ publish = false
[dependencies]
log = { workspace = true }
p12 = "0.6.3"
pem = "3"
rustls = { workspace = true, default-features = false, features = ["ring"] }
rustls-pemfile = "2"
rustls-platform-verifier = { workspace = true }
+129 -11
View File
@@ -18,7 +18,7 @@ pub mod error;
const OID_RSA_ENCRYPTION: &[u64] = &[1, 2, 840, 113549, 1, 1, 1];
const OID_EC_PUBLIC_KEY: &[u64] = &[1, 2, 840, 10045, 2, 1];
/// Password for the PKCS#12 blob [`load_client_identity_pkcs12`] builds from PEM
/// Password for the PKCS#12 blob [`load_native_client_identity`] builds from PEM
/// files. The blob never leaves the process, so the value only has to agree with
/// the caller that immediately re-parses it.
const IN_MEMORY_PKCS12_PASSWORD: &str = "yaak";
@@ -107,16 +107,33 @@ fn load_client_cert(
Ok(None)
}
/// Load the configured client certificate as PKCS#12 DER, along with the
/// password needed to open it.
/// A client identity in one of the encodings a native TLS stack accepts.
pub enum NativeClientIdentity {
/// A PKCS#12 archive, with the password needed to open it.
Pkcs12 { data: Vec<u8>, password: String },
/// A PEM certificate chain, leaf first, with a PKCS#8 PEM private key.
Pkcs8 {
chain_pem: Vec<u8>,
key_pem: Vec<u8>,
},
}
/// Whether the platform's native TLS stack should be handed PEM material as
/// PKCS#12 rather than PKCS#8.
///
/// Native TLS stacks accept a client identity as either PKCS#12 or a PKCS#8
/// PEM, and the PKCS#8 route rejects EC keys on macOS outright. Going through
/// PKCS#12 keeps the key formats we accept identical to the rustls path, which
/// reads PKCS#1 and SEC1 keys directly.
pub fn load_client_identity_pkcs12(
/// Both encodings lose something. PKCS#8 is rejected for EC keys by Security
/// Framework on macOS and by SChannel on Windows, which imports keys through an
/// RSA-only provider. PKCS#12 as the `p12` crate emits it is encrypted with
/// SHA1/40-bit-RC2 (certificates) and SHA1/3DES (key), and OpenSSL 3 moved RC2
/// into the legacy provider, so on Linux it fails to decrypt what we just
/// wrote. Each platform therefore gets the encoding its own stack can read.
const NATIVE_TLS_WANTS_PKCS12: bool = cfg!(any(target_vendor = "apple", target_os = "windows"));
/// Load the configured client certificate in whichever encoding this platform's
/// native TLS stack accepts.
pub fn load_native_client_identity(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<(Vec<u8>, String)>> {
) -> Result<Option<NativeClientIdentity>> {
let config = match client_cert {
None => return Ok(None),
Some(c) => c,
@@ -127,7 +144,10 @@ pub fn load_client_identity_pkcs12(
if let Some(pfx_path) = &config.pfx_file {
if !pfx_path.is_empty() {
let data = fs::read(Path::new(pfx_path))?;
return Ok(Some((data, config.passphrase.clone().unwrap_or_default())));
return Ok(Some(NativeClientIdentity::Pkcs12 {
data,
password: config.passphrase.clone().unwrap_or_default(),
}));
}
}
@@ -136,13 +156,35 @@ pub fn load_client_identity_pkcs12(
};
let key_der = to_pkcs8_der(&key)?;
if !NATIVE_TLS_WANTS_PKCS12 {
return Ok(Some(to_pkcs8_identity(&certs, &key_der)));
}
let (leaf, cas) = certs.split_first().ok_or(GenericError("No certificates found".into()))?;
let cas: Vec<&[u8]> = cas.iter().map(|c| c.as_ref()).collect();
let pfx = p12::PFX::new_with_cas(leaf, &key_der, &cas, IN_MEMORY_PKCS12_PASSWORD, "yaak")
.ok_or(GenericError("Failed to build PKCS#12 from client certificate".into()))?;
Ok(Some((pfx.to_der(), IN_MEMORY_PKCS12_PASSWORD.to_string())))
Ok(Some(NativeClientIdentity::Pkcs12 {
data: pfx.to_der(),
password: IN_MEMORY_PKCS12_PASSWORD.to_string(),
}))
}
/// Re-encode a certificate chain and PKCS#8 key as the PEM pair native-tls
/// expects. It only recognises a key whose first line is the PKCS#8 header, so
/// the key has to arrive already converted by [`to_pkcs8_der`].
fn to_pkcs8_identity(certs: &[CertificateDer<'static>], key_der: &[u8]) -> NativeClientIdentity {
let config = pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF);
let chain: Vec<pem::Pem> =
certs.iter().map(|c| pem::Pem::new("CERTIFICATE", c.as_ref())).collect();
NativeClientIdentity::Pkcs8 {
chain_pem: pem::encode_many_config(&chain, config).into_bytes(),
key_pem: pem::encode_config(&pem::Pem::new("PRIVATE KEY", key_der), config).into_bytes(),
}
}
/// Re-encode a private key as PKCS#8 DER, wrapping PKCS#1 and SEC1 keys.
@@ -379,3 +421,79 @@ pub fn find_client_certificate(
None
}
#[cfg(test)]
mod pkcs8_identity_tests {
use super::*;
const EC_CRT: &str = r#"-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIUB8703dqXCUOJQbhbyaMUMbVFOjwwCgYIKoZIzj0EAwIw
FzEVMBMGA1UEAwwMeWFhay10ZXN0LWVjMCAXDTI2MDgxNDIwNDYyNFoYDzIxMjYw
NzIxMjA0NjI0WjAXMRUwEwYDVQQDDAx5YWFrLXRlc3QtZWMwWTATBgcqhkjOPQIB
BggqhkjOPQMBBwNCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe1yeEA0VtfxFzMBg+
+bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QSo1MwUTAdBgNVHQ4EFgQUKq40
Hl+2DziVkBVR/tGsPj9FRo0wHwYDVR0jBBgwFoAUKq40Hl+2DziVkBVR/tGsPj9F
Ro0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAj1dx5XLl9iCZ
rD0CW+a3RTluxQ5icXno9WJ9qaS6L08CIFx2t0y9znQr7n5x+SmfXbfZtkDola8e
8nEZga/HXSeu
-----END CERTIFICATE-----"#;
const EC_SEC1_KEY: &str = r#"-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIoiiZ/hb4h6eHkZUVBTQFz7KLrVKJqQtWee2ygOjijNoAoGCCqGSM49
AwEHoUQDQgAEwmGCoc4BxGkWhrGFYyUqF76K6C/KntcnhANFbX8RczAYPvm5D0NJ
wrTMhX70ELV1mCGpMc7FicoSPMD41I+kEg==
-----END EC PRIVATE KEY-----"#;
const EC_PKCS8_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgiiKJn+FviHp4eRlR
UFNAXPsoutUompC1Z57bKA6OKM2hRANCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe
1yeEA0VtfxFzMBg++bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QS
-----END PRIVATE KEY-----"#;
fn pkcs8_identity(crt: &str, key: &str) -> (Vec<u8>, Vec<u8>) {
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(crt.as_bytes()))
.map(|c| c.unwrap())
.collect();
let key_der = to_pkcs8_der(&load_private_key(key.as_bytes()).unwrap()).unwrap();
match to_pkcs8_identity(&certs, &key_der) {
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => (chain_pem, key_pem),
NativeClientIdentity::Pkcs12 { .. } => unreachable!("asked for PKCS#8"),
}
}
/// native-tls matches the PKCS#8 header as a literal prefix and rejects the
/// key outright when it does not line up, so pin it on every platform even
/// though only the OpenSSL backend is handed this encoding.
#[test]
fn every_key_format_re_encodes_to_a_pkcs8_pem() {
for (name, key) in [("SEC1", EC_SEC1_KEY), ("PKCS#8", EC_PKCS8_KEY)] {
let (chain_pem, key_pem) = pkcs8_identity(EC_CRT, key);
assert!(
key_pem.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
"{name} key did not re-encode to a PKCS#8 PEM"
);
let round_tripped: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(chain_pem.as_slice()))
.map(|c| c.unwrap())
.collect();
let original: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(EC_CRT.as_bytes()))
.map(|c| c.unwrap())
.collect();
assert_eq!(round_tripped, original, "{name} chain did not round-trip");
}
}
/// The two on-disk spellings of one EC key have to converge, because only
/// the PKCS#8 one survives the re-encode.
#[test]
fn sec1_and_pkcs8_spellings_of_one_key_agree() {
let (_, from_sec1) = pkcs8_identity(EC_CRT, EC_SEC1_KEY);
let (_, from_pkcs8) = pkcs8_identity(EC_CRT, EC_PKCS8_KEY);
assert_eq!(from_sec1, from_pkcs8);
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod error;
pub mod export;
pub mod import;
pub mod models_ops;
pub mod plugin_events;
pub mod render;
pub mod send;
+89
View File
@@ -0,0 +1,89 @@
//! Generic model writes, shared by every host.
//!
//! `upsert`, `delete` and `duplicate` take an `AnyModel` and fan out to the
//! typed query for its variant. That fan-out is long, mechanical, and has to
//! grow a new arm every time a model is added — exactly the code that should
//! not exist twice. The host supplies the database handles and the
//! `UpdateSource` identifying who is writing; nothing here knows whether the
//! caller is a desktop window or an HTTP request.
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::error::Error::GenericError;
use yaak_models::error::Result;
use yaak_models::models::AnyModel;
use yaak_models::util::UpdateSource;
pub fn upsert_model(
db: &ClientDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
) -> Result<String> {
let id = match model {
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, blobs)?.id,
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
};
Ok(id)
}
/// Deletes cascade, so callers run this inside a transaction.
pub fn delete_model(
tx: &ClientDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
) -> Result<String> {
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
}
/// Duplicates recurse, so callers run this inside a transaction.
///
/// The model is re-read from the database rather than taken from the caller, so
/// a duplicate never comes from a stale frontend snapshot.
pub fn duplicate_model(
tx: &ClientDb,
model_type: &str,
model_id: &str,
source: &UpdateSource,
) -> Result<String> {
let id = match model_type {
"environment" => tx.duplicate_environment(&tx.get_environment(model_id)?, source)?.id,
"folder" => tx.duplicate_folder(&tx.get_folder(model_id)?, source)?.id,
"grpc_request" => tx.duplicate_grpc_request(&tx.get_grpc_request(model_id)?, source)?.id,
"http_request" => tx.duplicate_http_request(&tx.get_http_request(model_id)?, source)?.id,
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
}
+1
View File
@@ -58,6 +58,7 @@
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates/common/yaak-rpc-schema",
"crates/yaak-crypto",
"crates/yaak-git",
"crates/yaak-models",