Merge branch 'main' into chunk-data

This commit is contained in:
PaulKoudelka committed 2026-05-26 17:19:32 +02:00
commit 00b6c98f3b
167 files changed
+8153 -3577

No files matched your search

+2236 -2086
View File
File diff suppressed because it is too large. Load diff
+35 -30
View File
@@ -1,62 +1,67 @@
[package]
name = "mindwork-ai-studio"
version = "26.4.1"
edition = "2021"
edition = "2024"
description = "MindWork AI Studio"
authors = ["Thorsten Sommer"]
[build-dependencies]
tauri-build = { version = "1.5.6", features = [] }
tauri-build = { version = "2.6.1", features = [] }
[dependencies]
tauri = { version = "1.8.3", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog", "global-shortcut"] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri = { version = "2.11.1", features = [] }
tauri-plugin-window-state = { version = "2.4.1" }
tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2.5.4"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
keyring = { version = "3.6.2", features = ["apple-native", "windows-native", "sync-secret-service"] }
keyring-core = "1.0.0"
arboard = "3.6.1"
tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "macros", "process"] }
tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "macros", "process"] }
tokio-stream = "0.1.18"
futures = "0.3.32"
async-stream = "0.3.6"
flexi_logger = "0.31.8"
log = { version = "0.4.29", features = ["kv"] }
once_cell = "1.21.4"
rocket = { version = "0.5.1", features = ["json", "tls"] }
axum = { version = "0.8.9", features = ["http2", "json", "query", "tokio"] }
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
rustls = { version = "0.23.28", default-features = false, features = ["aws_lc_rs"] }
rand = "0.10.1"
rand_chacha = "0.10.0"
base64 = "0.22.1"
aes = "0.8.4"
cbc = "0.1.2"
pbkdf2 = "0.12.2"
hmac = "0.12.1"
sha2 = "0.10.8"
rcgen = { version = "0.14.7", features = ["pem"] }
file-format = "0.28.0"
calamine = "0.34.0"
pdfium-render = "0.8.37"
aes = "0.9.0"
cbc = "0.2.0"
pbkdf2 = "0.13.0"
hmac = "0.13.0"
sha2 = "0.11.0"
rcgen = { version = "0.14.8", features = ["pem"] }
file-format = "0.29.0"
calamine = "0.35.0"
pdfium-render = "0.9.1"
sys-locale = "0.3.2"
whoami = "2.1.2"
cfg-if = "1.0.4"
pptx-to-md = "0.4.0"
tempfile = "3.27.0"
strum_macros = "0.28.0"
sysinfo = "0.38.4"
tokenizers = "0.22.2"
# Fixes security vulnerability downstream, where the upstream is not fixed yet:
time = "0.3.47" # -> Rocket
bytes = "1.11.1" # -> almost every dependency
tar = "0.4.45" # -> Tauri v1
[target.'cfg(target_os = "linux")'.dependencies]
# See issue https://github.com/tauri-apps/tauri/issues/4470
reqwest = { version = "0.13.2", features = ["native-tls-vendored"] }
# Fixes security vulnerability downstream, where the upstream is not fixed yet:
openssl = "0.10.76" # -> reqwest, Tauri v1
sysinfo = "0.39.1"
bytes = "1.11.1"
[target.'cfg(target_os = "windows")'.dependencies]
windows-registry = "0.6.1"
windows-native-keyring-store = "1.0.0"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
[target.'cfg(target_os = "linux")'.dependencies]
dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2.10.0"
[features]
custom-protocol = ["tauri/custom-protocol"]
+26 -1
View File
@@ -53,6 +53,18 @@ fn update_cargo_toml(cargo_path: &str, version: &str) {
let cargo_toml_lines = cargo_toml.lines();
let mut new_cargo_toml = String::new();
// Return early when the version already matches to avoid unnecessary rewrites.
let current_version = cargo_toml.lines().find_map(|line| {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("\"version\": ")?;
let quoted = rest.strip_prefix('"')?;
let end_idx = quoted.find('"')?;
Some(&quoted[..end_idx])
});
if current_version == Some(version) {
return;
}
for line in cargo_toml_lines {
if line.starts_with("version = ") {
new_cargo_toml.push_str(&format!("version = \"{version}\""));
@@ -67,6 +79,19 @@ fn update_cargo_toml(cargo_path: &str, version: &str) {
fn update_tauri_conf(tauri_conf_path: &str, version: &str) {
let tauri_conf = std::fs::read_to_string(tauri_conf_path).unwrap();
// Return early when the version already matches to avoid unnecessary rewrites.
let current_version = tauri_conf.lines().find_map(|line| {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("\"version\": ")?;
let quoted = rest.strip_prefix('"')?;
let end_idx = quoted.find('"')?;
Some(&quoted[..end_idx])
});
if current_version == Some(version) {
return;
}
let tauri_conf_lines = tauri_conf.lines();
let mut new_tauri_conf = String::new();
@@ -75,7 +100,7 @@ fn update_tauri_conf(tauri_conf_path: &str, version: &str) {
// "version": "0.1.0-alpha.0"
// Please notice, that the version number line might have a leading tab, etc.
if line.contains("\"version\": ") {
new_tauri_conf.push_str(&format!("\t\"version\": \"{version}\""));
new_tauri_conf.push_str(&format!(" \"version\": \"{version}\","));
} else {
new_tauri_conf.push_str(line);
}
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for MindWork AI Studio",
"remote": {
"urls": [
"http://localhost:*"
]
},
"windows": [
"main"
],
"permissions": [
"core:default",
"updater:default",
"opener:default",
"shell:allow-open",
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "mindworkAIStudioServer",
"sidecar": true,
"args": true
},
{
"name": "qdrant",
"sidecar": true,
"args": true
}
]
}
]
}
+308 -174
View File
@@ -1,16 +1,23 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Mutex;
use std::time::Duration;
use async_stream::stream;
use axum::body::Body;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use axum::Json;
use bytes::Bytes;
use log::{debug, error, info, trace, warn};
use once_cell::sync::Lazy;
use rocket::{get, post};
use rocket::response::stream::TextStream;
use rocket::serde::json::Json;
use rocket::serde::Serialize;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use strum_macros::Display;
use tauri::updater::UpdateResponse;
use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent, generate_context};
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context};
use tauri::path::PathResolver;
use tauri::WebviewWindow;
use tauri_plugin_updater::{UpdaterExt, Update};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_opener::OpenerExt;
use tokio::sync::broadcast;
use tokio::time;
use crate::api_token::APIToken;
@@ -18,16 +25,16 @@ use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_serv
use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY};
use crate::log::switch_to_file_logging;
use crate::pdfium::PDFIUM_LIB_PATH;
use crate::qdrant::{cleanup_qdrant, start_qdrant_server, stop_qdrant_server};
use crate::qdrant::{start_qdrant_server, stop_qdrant_server};
#[cfg(debug_assertions)]
use crate::dotnet::create_startup_env_file;
use crate::tokenizer::set_path_resolver;
/// The Tauri main window.
static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));
pub static MAIN_WINDOW: Lazy<Mutex<Option<WebviewWindow>>> = Lazy::new(|| Mutex::new(None));
/// The update response coming from the Tauri updater.
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::new(None));
/// The event broadcast sender for Tauri events.
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
@@ -35,6 +42,9 @@ static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::ne
/// Stores the currently registered global shortcuts (name -> shortcut string).
static REGISTERED_SHORTCUTS: Lazy<Mutex<HashMap<Shortcut, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Stores the localhost origin of the Blazor app after the .NET server is ready.
static APPROVED_APP_URL: Lazy<Mutex<Option<tauri::Url>>> = Lazy::new(|| Mutex::new(None));
/// Enum identifying global keyboard shortcuts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
@@ -76,10 +86,34 @@ pub fn start_tauri() {
});
let app = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(
tauri::plugin::Builder::<tauri::Wry, ()>::new("external-link-handler")
.on_navigation(|webview, url| {
if !should_open_in_system_browser(webview, url) {
return true;
}
match webview.app_handle().opener().open_url(url.as_str(), None::<&str>) {
Ok(_) => {
info!(Source = "Tauri"; "Opening external URL in system browser: {url}");
},
Err(error) => {
error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}");
},
}
false
})
.build(),
)
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| {
// Get the main window:
let window = app.get_window("main").expect("Failed to get main window.");
let window = app.get_webview_window("main").expect("Failed to get main window.");
// Register a callback for window events, such as file drops. We have to use
// this handler in addition to the app event handler, because file drop events
@@ -100,29 +134,28 @@ pub fn start_tauri() {
*MAIN_WINDOW.lock().unwrap() = Some(window);
info!(Source = "Bootloader Tauri"; "Setup is running.");
let data_path = app.path_resolver().app_local_data_dir().unwrap();
let data_path = app.path().app_local_data_dir().unwrap();
let data_path = data_path.join("data");
// Get and store the data and config directories:
DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the data directory.")).unwrap();
CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
CONFIG_DIRECTORY.set(app.path().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
if is_dev() {
#[cfg(debug_assertions)]
create_startup_env_file();
} else {
cleanup_dotnet_server();
start_dotnet_server();
start_dotnet_server(app.handle().clone());
}
cleanup_qdrant();
start_qdrant_server(app.path_resolver());
start_qdrant_server(app.handle().clone());
set_path_resolver(app.path_resolver());
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}");
switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap();
set_pdfium_path(app.path_resolver());
set_pdfium_path(app.path());
Ok(())
})
@@ -131,7 +164,7 @@ pub fn start_tauri() {
.expect("Error while running Tauri application");
// The app event handler:
app.run(|app_handle, event| {
app.run(|_app_handle, event| {
if !matches!(event, RunEvent::MainEventsCleared) {
debug!(Source = "Tauri"; "Tauri event received: location=app event handler , event={event:?}");
}
@@ -151,54 +184,6 @@ pub fn start_tauri() {
}
}
RunEvent::Updater(updater_event) => {
match updater_event {
UpdaterEvent::UpdateAvailable { body, date, version } => {
let body_len = body.len();
info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}");
}
UpdaterEvent::Pending => {
info!(Source = "Tauri"; "Updater: update is pending!");
}
UpdaterEvent::DownloadProgress { chunk_length, content_length: _ } => {
trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes");
}
UpdaterEvent::Downloaded => {
info!(Source = "Tauri"; "Updater: update has been downloaded!");
warn!(Source = "Tauri"; "Try to stop the .NET server now...");
if is_prod() {
stop_dotnet_server();
stop_qdrant_server();
} else {
warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server.");
}
}
UpdaterEvent::Updated => {
info!(Source = "Tauri"; "Updater: app has been updated");
warn!(Source = "Tauri"; "Try to restart the app now...");
if is_prod() {
app_handle.restart();
} else {
warn!(Source = "Tauri"; "Development environment detected; do not restart the app.");
}
}
UpdaterEvent::AlreadyUpToDate => {
info!(Source = "Tauri"; "Updater: app is already up to date");
}
UpdaterEvent::Error(error) => {
warn!(Source = "Tauri"; "Updater: failed to update: {error}");
}
}
}
RunEvent::ExitRequested { .. } => {
warn!(Source = "Tauri"; "Run event: exit was requested.");
stop_qdrant_server();
@@ -219,13 +204,62 @@ pub fn start_tauri() {
warn!(Source = "Tauri"; "Tauri app was stopped.");
}
fn is_local_host(host: Option<&str>) -> bool {
matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]"))
}
fn is_tauri_asset_host(host: Option<&str>) -> bool {
matches!(host, Some("tauri.localhost"))
}
fn is_tauri_asset_url(url: &tauri::Url) -> bool {
matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str())
}
fn is_local_http_url(url: &tauri::Url) -> bool {
matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str())
}
fn same_origin(left: &tauri::Url, right: &tauri::Url) -> bool {
left.scheme() == right.scheme()
&& left.host_str() == right.host_str()
&& left.port_or_known_default() == right.port_or_known_default()
}
fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>, url: &tauri::Url) -> bool {
match url.scheme() {
"mailto" | "tel" => return true,
"http" | "https" => {},
_ => return false,
}
if is_tauri_asset_url(url) {
return false;
}
if let Some(approved_app_url) = APPROVED_APP_URL.lock().unwrap().as_ref() {
if same_origin(approved_app_url, url) {
return false;
}
if is_local_http_url(url) {
return true;
}
}
if let Ok(current_url) = webview.url() && same_origin(&current_url, url) {
return false;
}
!is_local_host(url.host_str())
}
/// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client.
/// If no events are available for a certain time, we send a ping event to keep the connection alive.
/// When the client disconnects, the stream is closed. But we try to not lose events in between.
/// The client is expected to reconnect automatically when the connection is closed and continue
/// listening for events.
#[get("/events")]
pub async fn get_event_stream(_token: APIToken) -> TextStream![String] {
pub async fn get_event_stream(_token: APIToken) -> Response {
// Get the lock to the event broadcast sender:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
@@ -237,8 +271,7 @@ pub async fn get_event_stream(_token: APIToken) -> TextStream![String] {
// Drop the lock to allow other access to the sender:
drop(event_broadcast_lock);
// Create the event stream:
TextStream! {
let stream = stream! {
loop {
// Wait at most 3 seconds for an event:
match time::timeout(Duration::from_secs(3), event_receiver.recv()).await {
@@ -249,11 +282,11 @@ pub async fn get_event_stream(_token: APIToken) -> TextStream![String] {
// is serialized as a single line so that the client can parse it
// correctly:
let event_json = serde_json::to_string(&event).unwrap();
yield event_json;
yield Ok::<Bytes, Infallible>(Bytes::from(event_json));
// The client expects a newline after each event because we are using
// a method to read the stream line-by-line:
yield "\n".to_string();
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
},
// Case: we lagged behind and missed some events
@@ -273,15 +306,17 @@ pub async fn get_event_stream(_token: APIToken) -> TextStream![String] {
// Again, we have to serialize the event as a single line:
let event_json = serde_json::to_string(&ping_event).unwrap();
yield event_json;
yield Ok::<Bytes, Infallible>(Bytes::from(event_json));
// The client expects a newline after each event because we are using
// a method to read the stream line-by-line:
yield "\n".to_string();
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
},
}
}
}
};
([(CONTENT_TYPE, "application/jsonl")], Body::from_stream(stream)).into_response()
}
/// Data structure representing a Tauri event for our event API.
@@ -305,23 +340,21 @@ impl Event {
/// Creates an Event instance from a Tauri WindowEvent.
pub fn from_window_event(window_event: &WindowEvent) -> Self {
match window_event {
WindowEvent::FileDrop(drop_event) => {
WindowEvent::DragDrop(drop_event) => {
match drop_event {
FileDropEvent::Hovered(files) => Event::new(TauriEventType::FileDropHovered,
files.iter().map(|f| f.to_string_lossy().to_string()).collect(),
DragDropEvent::Enter { paths, .. } => Event::new(
TauriEventType::FileDropHovered,
paths.iter().map(|p| p.display().to_string()).collect(),
),
FileDropEvent::Dropped(files) => Event::new(TauriEventType::FileDropDropped,
files.iter().map(|f| f.to_string_lossy().to_string()).collect(),
DragDropEvent::Drop { paths, .. } => Event::new(
TauriEventType::FileDropDropped,
paths.iter().map(|p| p.display().to_string()).collect(),
),
FileDropEvent::Cancelled => Event::new(TauriEventType::FileDropCanceled,
Vec::new(),
),
DragDropEvent::Leave => Event::new(TauriEventType::FileDropCanceled, Vec::new()),
_ => Event::new(TauriEventType::Unknown,
Vec::new(),
),
_ => Event::new(TauriEventType::Unknown, Vec::new()),
}
},
@@ -382,6 +415,10 @@ pub async fn change_location_to(url: &str) {
}
}
if let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) {
*APPROVED_APP_URL.lock().unwrap() = Some(parsed_url);
}
let js_location_change = format!("window.location = '{url}';");
let main_window = main_window_spawn_clone.lock().unwrap();
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
@@ -392,7 +429,6 @@ pub async fn change_location_to(url: &str) {
}
/// Checks for updates.
#[get("/updates/check")]
pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
if is_dev() {
warn!(Source = "Updater"; "The app is running in development mode; skipping update check.");
@@ -404,46 +440,67 @@ pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
});
}
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle();
let response = app_handle.updater().check().await;
match response {
Ok(update_response) => match update_response.is_update_available() {
true => {
*CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone());
let new_version = update_response.latest_version();
info!(Source = "Updater"; "An update to version '{new_version}' is available.");
let changelog = update_response.body();
Json(CheckUpdateResponse {
update_is_available: true,
error: false,
new_version: new_version.to_string(),
changelog: match changelog {
Some(c) => c.to_string(),
None => String::from(""),
},
})
},
false => {
info!(Source = "Updater"; "No updates are available.");
Json(CheckUpdateResponse {
let app_handle = {
let main_window = MAIN_WINDOW.lock().unwrap();
match main_window.as_ref() {
Some(window) => window.app_handle().clone(),
None => {
error!(Source = "Updater"; "Cannot check updates: main window not available.");
return Json(CheckUpdateResponse {
update_is_available: false,
error: false,
error: true,
new_version: String::from(""),
changelog: String::from(""),
})
},
},
});
}
}
};
let response = match app_handle.updater() {
Ok(updater) => updater.check().await,
Err(e) => {
warn!(Source = "Updater"; "Failed to check for updates: {e}.");
warn!(Source = "Updater"; "Failed to get updater instance: {e}");
return Json(CheckUpdateResponse {
update_is_available: false,
error: true,
new_version: String::from(""),
changelog: String::from(""),
});
}
};
match response {
Ok(Some(update)) => {
let body_len = update.body.as_ref().map_or(0, |body| body.len());
let date = update.date;
let new_version = update.version.clone();
info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={new_version}");
let changelog = update.body.clone().unwrap_or_default();
*CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update);
Json(CheckUpdateResponse {
update_is_available: true,
error: false,
new_version,
changelog,
})
}
Ok(None) => {
info!(Source = "Tauri"; "Updater: app is already up to date");
Json(CheckUpdateResponse {
update_is_available: false,
error: false,
new_version: String::from(""),
changelog: String::from(""),
})
}
Err(e) => {
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
Json(CheckUpdateResponse {
update_is_available: false,
error: true,
new_version: String::from(""),
changelog: String::from(""),
})
},
}
}
}
@@ -457,7 +514,6 @@ pub struct CheckUpdateResponse {
}
/// Installs the update.
#[get("/updates/install")]
pub async fn install_update(_token: APIToken) {
if is_dev() {
warn!(Source = "Updater"; "The app is running in development mode; skipping update installation.");
@@ -465,9 +521,51 @@ pub async fn install_update(_token: APIToken) {
}
let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone();
let app_handle = MAIN_WINDOW
.lock()
.unwrap()
.as_ref()
.map(|window| window.app_handle().clone());
match cloned_response_option {
Some(update_response) => {
update_response.download_and_install().await.unwrap();
info!(Source = "Tauri"; "Updater: update is pending!");
let result = update_response.download_and_install(
|chunk_length, _content_length| {
trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes");
},
|| {
info!(Source = "Tauri"; "Updater: update has been downloaded!");
warn!(Source = "Tauri"; "Try to stop the .NET server now...");
if is_prod() {
stop_dotnet_server();
stop_qdrant_server();
} else {
warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server.");
}
},
).await;
match result {
Ok(_) => {
info!(Source = "Tauri"; "Updater: app has been updated");
warn!(Source = "Tauri"; "Try to restart the app now...");
if is_prod() {
if let Some(handle) = app_handle {
handle.restart();
} else {
warn!(Source = "Tauri"; "Cannot restart after update: main window not available.");
}
} else {
warn!(Source = "Tauri"; "Development environment detected; do not restart the app.");
}
}
Err(e) => {
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
}
}
},
None => {
@@ -504,47 +602,41 @@ pub struct AppExitResponse {
/// Internal helper function to register a shortcut with its callback.
/// This is used by both `register_shortcut` and `resume_shortcuts` to
/// avoid code duplication.
fn register_shortcut_with_callback(
shortcut_manager: &mut impl GlobalShortcutManager,
fn register_shortcut_with_callback<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
shortcut: &str,
shortcut_id: Shortcut,
event_sender: broadcast::Sender<Event>,
) -> Result<(), tauri::Error> {
//
// Match the shortcut registration to transform the Tauri result into the Rust result:
//
match shortcut_manager.register(shortcut, move || {
) -> Result<(), tauri_plugin_global_shortcut::Error> {
let shortcut_manager = app_handle.global_shortcut();
shortcut_manager.on_shortcut(shortcut, move |_app, _shortcut, _event| {
info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id);
let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_id.to_string()]);
let sender = event_sender.clone();
tauri::async_runtime::spawn(async move {
match sender.send(event) {
Ok(_) => {}
Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"),
if let Err(error) = sender.send(event) {
error!(Source = "Tauri"; "Failed to send global shortcut event: {error}");
}
});
}) {
Ok(_) => Ok(()),
Err(e) => Err(e.into()),
}
})
}
/// Requests a controlled shutdown of the entire desktop application.
#[post("/app/exit")]
pub fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot exit app: main window not available.");
return Json(AppExitResponse {
success: false,
error_message: "Main window not available".to_string(),
});
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
let app_handle = {
let main_window_lock = MAIN_WINDOW.lock().unwrap();
match main_window_lock.as_ref() {
Some(window) => window.app_handle().clone(),
None => {
error!(Source = "Tauri"; "Cannot exit app: main window not available.");
return Json(AppExitResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
}
};
let app_handle = main_window.app_handle();
info!(Source = "Tauri"; "Controlled app exit was requested by the UI.");
tauri::async_runtime::spawn(async move {
time::sleep(Duration::from_millis(50)).await;
@@ -559,8 +651,7 @@ pub fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
/// Registers or updates a global shortcut. If the shortcut string is empty,
/// the existing shortcut for that name will be unregistered.
#[post("/shortcuts/register", data = "<payload>")]
pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
let id = payload.id;
let new_shortcut = payload.shortcut.clone();
@@ -587,16 +678,15 @@ pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest
}
};
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
let app_handle = main_window.app_handle();
let shortcut_manager = app_handle.global_shortcut();
let mut registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Unregister the old shortcut if one exists for this name:
if let Some(old_shortcut) = registered_shortcuts.get(&id) {
if !old_shortcut.is_empty() {
match shortcut_manager.unregister(old_shortcut.as_str()) {
Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id),
Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"),
}
if let Some(old_shortcut) = registered_shortcuts.get(&id) && !old_shortcut.is_empty() {
match shortcut_manager.unregister(old_shortcut.as_str()) {
Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id),
Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"),
}
}
@@ -626,7 +716,7 @@ pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest
drop(event_broadcast_lock);
// Register the new shortcut:
match register_shortcut_with_callback(&mut shortcut_manager, &new_shortcut, id, event_sender) {
match register_shortcut_with_callback(app_handle, &new_shortcut, id, event_sender) {
Ok(_) => {
info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id);
registered_shortcuts.insert(id, new_shortcut);
@@ -666,8 +756,7 @@ pub struct ShortcutValidationResponse {
/// Validates a shortcut string without registering it.
/// Checks if the shortcut syntax is valid and if it
/// conflicts with existing shortcuts.
#[post("/shortcuts/validate", data = "<payload>")]
pub fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
let shortcut = payload.shortcut.clone();
// Empty shortcuts are always valid (means "disabled"):
@@ -721,8 +810,7 @@ pub fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest
/// The shortcuts remain in our internal map, so they can be re-registered on resume.
/// This is useful when opening a dialog to configure shortcuts, so the user can
/// press the current shortcut to re-enter it without triggering the action.
#[post("/shortcuts/suspend")]
pub fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
pub async fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
// Get the main window to access the global shortcut manager:
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
@@ -736,7 +824,8 @@ pub fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
}
};
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
let app_handle = main_window.app_handle();
let shortcut_manager = app_handle.global_shortcut();
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Unregister all shortcuts from the OS (but keep them in our map):
@@ -757,8 +846,7 @@ pub fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
}
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
#[post("/shortcuts/resume")]
pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
pub async fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
// Get the main window to access the global shortcut manager:
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
@@ -772,7 +860,7 @@ pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
}
};
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
let app_handle = main_window.app_handle();
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Get the event broadcast sender for the shortcut callbacks:
@@ -797,7 +885,7 @@ pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
continue;
}
match register_shortcut_with_callback(&mut shortcut_manager, shortcut, *shortcut_id, event_sender.clone()) {
match register_shortcut_with_callback(app_handle, shortcut, *shortcut_id, event_sender.clone()) {
Ok(_) => {
info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id);
success_count += 1;
@@ -858,15 +946,61 @@ fn validate_shortcut_syntax(shortcut: &str) -> bool {
has_key
}
fn set_pdfium_path(path_resolver: PathResolver) {
let pdfium_relative_source_path = String::from("resources/libraries/");
let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path);
if pdfium_source_path.is_none() {
error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path.");
return;
fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
let resource_dir = match path_resolver.resource_dir() {
Ok(path) => path,
Err(error) => {
error!(Source = "Bootloader Tauri"; "Failed to resolve resource dir: {error}");
return;
}
};
let candidate_paths = [
resource_dir.join("resources").join("libraries"),
resource_dir.join("libraries"),
];
let pdfium_source_path = candidate_paths
.iter()
.find(|path| path.exists())
.map(|path| path.to_string_lossy().to_string());
match pdfium_source_path {
Some(path) => {
*PDFIUM_LIB_PATH.lock().unwrap() = Some(path);
}
None => {
error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path.");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tauri_localhost_is_tauri_asset_url() {
let https_url = tauri::Url::parse("https://tauri.localhost/index.html").unwrap();
let http_url = tauri::Url::parse("http://tauri.localhost/index.html").unwrap();
assert!(is_tauri_asset_url(&https_url));
assert!(is_tauri_asset_url(&http_url));
}
let pdfium_source_path = pdfium_source_path.unwrap();
let pdfium_source_path = pdfium_source_path.to_str().unwrap().to_string();
*PDFIUM_LIB_PATH.lock().unwrap() = Some(pdfium_source_path.clone());
}
#[test]
fn localhost_app_url_is_not_tauri_asset_url() {
let url = tauri::Url::parse("http://localhost:12345/").unwrap();
assert!(!is_tauri_asset_url(&url));
assert!(is_local_http_url(&url));
}
#[test]
fn external_url_is_not_internal_url() {
let url = tauri::Url::parse("https://example.com/").unwrap();
assert!(!is_tauri_asset_url(&url));
assert!(!is_local_http_url(&url));
}
}
+3 -4
View File
@@ -1,14 +1,13 @@
use arboard::Clipboard;
use log::{debug, error};
use rocket::post;
use rocket::serde::json::Json;
use axum::Json;
use serde::Serialize;
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// Sets the clipboard text to the provided encrypted text.
#[post("/clipboard/set", data = "<encrypted_text>")]
pub fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json<SetClipboardResponse> {
pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<SetClipboardResponse> {
let encrypted_text = EncryptedText::new(encrypted_text);
// Decrypt this text first:
let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) {
+19 -16
View File
@@ -5,9 +5,9 @@ use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use log::{error, info, warn};
use once_cell::sync::Lazy;
use rocket::get;
use tauri::api::process::{Command, CommandChild, CommandEvent};
use tauri::Url;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use crate::api_token::APIToken;
use crate::runtime_api_token::API_TOKEN;
use crate::app_window::change_location_to;
@@ -88,8 +88,7 @@ fn sanitize_stdout_line(line: &str) -> String {
/// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get
/// the port where the .NET server should listen to.
#[get("/system/dotnet/port")]
pub fn dotnet_port(_token: APIToken) -> String {
pub async fn dotnet_port(_token: APIToken) -> String {
let dotnet_server_port = *DOTNET_SERVER_PORT;
format!("{dotnet_server_port}")
}
@@ -130,14 +129,14 @@ pub fn create_startup_env_file() {
}
/// Starts the .NET server in a separate process.
pub fn start_dotnet_server() {
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
// Get the secret password & salt and convert it to a base64 string:
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
let api_port = *API_SERVER_PORT;
let dotnet_server_environment = HashMap::from_iter([
let dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
(String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
@@ -148,11 +147,13 @@ pub fn start_dotnet_server() {
info!("Try to start the .NET server...");
let server_spawn_clone = DOTNET_SERVER.clone();
tauri::async_runtime::spawn(async move {
let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer")
.expect("Failed to create sidecar")
.envs(dotnet_server_environment)
.spawn()
.expect("Failed to spawn .NET server process.");
let shell = app_handle.shell();
let (mut rx, child) = shell
.sidecar("mindworkAIStudioServer")
.expect("Failed to create sidecar")
.envs(dotnet_server_environment)
.spawn()
.expect("Failed to spawn .NET server process.");
let server_pid = child.pid();
info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}.");
log_potential_stale_process(Path::new(DATA_DIRECTORY.get().unwrap()).join(PID_FILE_NAME), server_pid, SIDECAR_TYPE);
@@ -163,17 +164,19 @@ pub fn start_dotnet_server() {
// Log the output of the .NET server:
// NOTE: Log events are sent via structured HTTP API calls.
// This loop serves for fundamental output (e.g., startup errors).
while let Some(CommandEvent::Stdout(line)) = rx.recv().await {
let line = sanitize_stdout_line(line.trim_end());
if !line.trim().is_empty() {
info!(Source = ".NET Server (stdout)"; "{line}");
while let Some(event) = rx.recv().await {
if let CommandEvent::Stdout(line) = event {
let line_utf8 = String::from_utf8_lossy(&line).to_string();
let line = sanitize_stdout_line(line_utf8.trim_end());
if !line.trim().is_empty() {
info!(Source = ".NET Server (stdout)"; "{line}");
}
}
}
});
}
/// This endpoint is called by the .NET server to signal that the server is ready.
#[get("/system/dotnet/ready")]
pub async fn dotnet_ready(_token: APIToken) {
// We create a manual scope for the lock to be released as soon as possible.
+4 -33
View File
@@ -2,26 +2,20 @@ use std::fmt;
use std::time::Instant;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
use hmac::Hmac;
use log::{error, info};
use once_cell::sync::Lazy;
use pbkdf2::pbkdf2;
use rand::rngs::SysRng;
use rand::{Rng, SeedableRng};
use rocket::{data, Data, Request};
use rocket::data::ToByteUnit;
use rocket::http::Status;
use rocket::serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize};
use sha2::Sha512;
use tokio::io::AsyncReadExt;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
type DataOutcome<'r, T> = data::Outcome<'r, T>;
/// The encryption instance used for the IPC channel.
pub static ENCRYPTION: Lazy<Encryption> = Lazy::new(|| {
//
@@ -113,7 +107,7 @@ impl Encryption {
let mut buffer = vec![0u8; data.len() + 16];
buffer[..data.len()].copy_from_slice(data);
let encrypted = cipher
.encrypt_padded_mut::<Pkcs7>(&mut buffer, data.len())
.encrypt_padded::<Pkcs7>(&mut buffer, data.len())
.map_err(|e| format!("Error encrypting data: {e}"))?;
let mut result = BASE64_STANDARD.encode(self.secret_key_salt);
result.push_str(&BASE64_STANDARD.encode(encrypted));
@@ -136,7 +130,7 @@ impl Encryption {
let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into());
let mut buffer = encrypted.to_vec();
let decrypted = cipher
.decrypt_padded_mut::<Pkcs7>(&mut buffer)
.decrypt_padded::<Pkcs7>(&mut buffer)
.map_err(|e| format!("Error decrypting data: {e}"))?;
String::from_utf8(decrypted.to_vec()).map_err(|e| format!("Error converting decrypted data to string: {}", e))
@@ -170,27 +164,4 @@ impl fmt::Display for EncryptedText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "**********")
}
}
/// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request).
/// We must interpret the body as EncryptedText.
#[rocket::async_trait]
impl<'r> data::FromData<'r> for EncryptedText {
type Error = String;
/// Parses the data as EncryptedText.
async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> {
let content_type = req.content_type();
if content_type.map_or(true, |ct| !ct.is_text()) {
return DataOutcome::Forward((data, Status::Ok));
}
let mut stream = data.open(2.mebibytes());
let mut body = String::new();
if let Err(e) = stream.read_to_string(&mut body).await {
return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e)));
}
DataOutcome::Success(EncryptedText(body))
}
}
+22 -25
View File
@@ -1,7 +1,6 @@
use crate::api_token::APIToken;
use log::{debug, info, warn};
use rocket::get;
use rocket::serde::json::Json;
use axum::Json;
use log::{debug, error, info, warn};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::env;
@@ -29,8 +28,7 @@ pub static CONFIG_DIRECTORY: OnceLock<String> = OnceLock::new();
static USER_LANGUAGE: OnceLock<String> = OnceLock::new();
/// Returns the config directory.
#[get("/system/directories/config")]
pub fn get_config_directory(_token: APIToken) -> String {
pub async fn get_config_directory(_token: APIToken) -> String {
match CONFIG_DIRECTORY.get() {
Some(config_directory) => config_directory.clone(),
None => String::from(""),
@@ -38,14 +36,21 @@ pub fn get_config_directory(_token: APIToken) -> String {
}
/// Returns the data directory.
#[get("/system/directories/data")]
pub fn get_data_directory(_token: APIToken) -> String {
pub async fn get_data_directory(_token: APIToken) -> String {
match DATA_DIRECTORY.get() {
Some(data_directory) => data_directory.clone(),
None => String::from(""),
}
}
/// Returns the current user's username.
pub async fn read_user_name(_token: APIToken) -> String {
whoami::username().unwrap_or_else(|e| {
error!("Failed to read the current OS username: {e}.");
String::new()
})
}
/// Returns true if the application is running in development mode.
pub fn is_dev() -> bool {
cfg!(debug_assertions)
@@ -90,10 +95,8 @@ fn normalize_locale_tag(locale: &str) -> Option<String> {
return None;
}
if let Some(region) = segments.next() {
if region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) {
return Some(format!("{}-{}", language, region.to_ascii_uppercase()));
}
if let Some(region) = segments.next() && region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) {
return Some(format!("{}-{}", language, region.to_ascii_uppercase()));
}
Some(language)
@@ -150,8 +153,7 @@ fn detect_user_language() -> (String, LanguageDetectionSource) {
)
}
#[get("/system/language")]
pub fn read_user_language(_token: APIToken) -> String {
pub async fn read_user_language(_token: APIToken) -> String {
USER_LANGUAGE
.get_or_init(|| {
let (user_language, source) = detect_user_language();
@@ -194,8 +196,7 @@ struct EnterpriseSourceData {
encryption_secret: String,
}
#[get("/system/enterprise/config/id")]
pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
pub async fn read_enterprise_env_config_id(_token: APIToken) -> String {
debug!("Trying to read the effective enterprise configuration ID.");
resolve_effective_enterprise_config_source()
.configs
@@ -205,8 +206,7 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
.unwrap_or_default()
}
#[get("/system/enterprise/config/server")]
pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
pub async fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
debug!("Trying to read the effective enterprise configuration server URL.");
resolve_effective_enterprise_config_source()
.configs
@@ -216,15 +216,13 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
.unwrap_or_default()
}
#[get("/system/enterprise/config/encryption_secret")]
pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String {
pub async fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String {
debug!("Trying to read the effective enterprise configuration encryption secret.");
resolve_effective_enterprise_secret_source().encryption_secret
}
/// Returns all enterprise configurations from the effective source.
#[get("/system/enterprise/configs")]
pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>> {
pub async fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>> {
info!("Trying to read the effective enterprise configurations.");
Json(resolve_effective_enterprise_config_source().configs)
}
@@ -426,10 +424,9 @@ fn load_policy_values_from_directories(directories: &[PathBuf]) -> HashMap<Strin
}
let secret_path = directory.join(ENTERPRISE_POLICY_SECRET_FILE_NAME);
if let Some(secret_values) = read_policy_yaml_mapping(&secret_path) {
if let Some(secret) = secret_values.get("config_encryption_secret") {
insert_first_non_empty_value(&mut values, "config_encryption_secret", secret);
}
if let Some(secret_values) = read_policy_yaml_mapping(&secret_path)
&& let Some(secret) = secret_values.get("config_encryption_secret") {
insert_first_non_empty_value(&mut values, "config_encryption_secret", secret);
}
}
+155 -97
View File
@@ -1,15 +1,21 @@
use log::info;
use rocket::post;
use rocket::serde::{Deserialize, Serialize};
use rocket::serde::json::Json;
use tauri::api::dialog::blocking::FileDialogBuilder;
use log::{error, info};
use axum::extract::Query;
use axum::Json;
use serde::{Deserialize, Serialize};
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
use crate::api_token::APIToken;
use crate::app_window::MAIN_WINDOW;
#[derive(Clone, Deserialize)]
pub struct PreviousDirectory {
path: String,
}
#[derive(Deserialize)]
pub struct SelectDirectoryQuery {
title: String,
}
#[derive(Clone, Deserialize)]
pub struct FileTypeFilter {
filter_name: String,
@@ -60,31 +66,49 @@ pub struct PreviousFile {
}
/// Let the user select a directory.
#[post("/select/directory?<title>", data = "<previous_directory>")]
pub fn select_directory(_token: APIToken, title: &str, previous_directory: Option<Json<PreviousDirectory>>) -> Json<DirectorySelectionResponse> {
let folder_path = match previous_directory {
Some(previous) => {
let previous_path = previous.path.as_str();
FileDialogBuilder::new()
.set_title(title)
.set_directory(previous_path)
.pick_folder()
},
pub async fn select_directory(
_token: APIToken,
Query(query): Query<SelectDirectoryQuery>,
previous_directory: Option<Json<PreviousDirectory>>,
) -> Json<DirectorySelectionResponse> {
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
Some(window) => window,
None => {
FileDialogBuilder::new()
.set_title(title)
.pick_folder()
},
error!(Source = "Tauri"; "Cannot open directory dialog: main window not available.");
return Json(DirectorySelectionResponse {
user_cancelled: true,
selected_directory: String::from(""),
});
}
};
let mut dialog = main_window.dialog().file().set_parent(main_window).set_title(&query.title);
if let Some(previous) = previous_directory {
dialog = dialog.set_directory(previous.path.clone());
}
drop(main_window_lock);
let folder_path = dialog.blocking_pick_folder();
match folder_path {
Some(path) => {
info!("User selected directory: {path:?}");
Json(DirectorySelectionResponse {
user_cancelled: false,
selected_directory: path.to_str().unwrap().to_string(),
})
match path.into_path() {
Ok(pb) => {
info!("User selected directory: {pb:?}");
Json(DirectorySelectionResponse {
user_cancelled: false,
selected_directory: pb.to_string_lossy().to_string(),
})
}
Err(e) => {
error!(Source = "Tauri"; "Failed to convert directory path: {e}");
Json(DirectorySelectionResponse {
user_cancelled: true,
selected_directory: String::new(),
})
}
}
},
None => {
@@ -98,37 +122,52 @@ pub fn select_directory(_token: APIToken, title: &str, previous_directory: Optio
}
/// Let the user select a file.
#[post("/select/file", data = "<payload>")]
pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<FileSelectionResponse> {
pub async fn select_file(
_token: APIToken,
payload: Json<SelectFileOptions>,
) -> Json<FileSelectionResponse> {
// Create a new file dialog builder:
let file_dialog = FileDialogBuilder::new();
let file_dialog = MAIN_WINDOW
.lock()
.unwrap()
.as_ref()
.map(|w| w.dialog().file().set_parent(w).set_title(&payload.title));
// Set the title of the file dialog:
let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided:
let file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
let file_dialog = match &payload.previous_file {
Some(previous) => {
let previous_path = previous.file_path.as_str();
file_dialog.set_directory(previous_path)
},
None => file_dialog,
let Some(mut file_dialog) = file_dialog else {
error!(Source = "Tauri"; "Cannot open file dialog: main window not available.");
return Json(FileSelectionResponse {
user_cancelled: true,
selected_file_path: String::from(""),
});
};
// Set the file type filter if provided:
file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
if let Some(previous) = &payload.previous_file {
let previous_path = previous.file_path.as_str();
file_dialog = file_dialog.set_directory(previous_path);
}
// Show the file dialog and get the selected file path:
let file_path = file_dialog.pick_file();
let file_path = file_dialog.blocking_pick_file();
match file_path {
Some(path) => {
info!("User selected file: {path:?}");
Json(FileSelectionResponse {
user_cancelled: false,
selected_file_path: path.to_str().unwrap().to_string(),
})
Some(path) => match path.into_path() {
Ok(pb) => {
info!("User selected file: {pb:?}");
Json(FileSelectionResponse {
user_cancelled: false,
selected_file_path: pb.to_string_lossy().to_string(),
})
}
Err(e) => {
error!(Source = "Tauri"; "Failed to convert file path: {e}");
Json(FileSelectionResponse {
user_cancelled: true,
selected_file_path: String::new(),
})
}
},
None => {
@@ -142,36 +181,43 @@ pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<F
}
/// Let the user select some files.
#[post("/select/files", data = "<payload>")]
pub fn select_files(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<FilesSelectionResponse> {
pub async fn select_files(
_token: APIToken,
payload: Json<SelectFileOptions>,
) -> Json<FilesSelectionResponse> {
// Create a new file dialog builder:
let file_dialog = FileDialogBuilder::new();
let file_dialog = MAIN_WINDOW
.lock()
.unwrap()
.as_ref()
.map(|w| w.dialog().file().set_parent(w).set_title(&payload.title));
// Set the title of the file dialog:
let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided:
let file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
let file_dialog = match &payload.previous_file {
Some(previous) => {
let previous_path = previous.file_path.as_str();
file_dialog.set_directory(previous_path)
},
None => file_dialog,
let Some(mut file_dialog) = file_dialog else {
error!(Source = "Tauri"; "Cannot open file dialog: main window not available.");
return Json(FilesSelectionResponse {
user_cancelled: true,
selected_file_paths: Vec::new(),
});
};
// Set the file type filter if provided:
file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
if let Some(previous) = &payload.previous_file {
let previous_path = previous.file_path.as_str();
file_dialog = file_dialog.set_directory(previous_path);
}
// Show the file dialog and get the selected file path:
let file_paths = file_dialog.pick_files();
let file_paths = file_dialog.blocking_pick_files();
match file_paths {
Some(paths) => {
info!("User selected {} files.", paths.len());
let converted: Vec<String> = paths.into_iter().filter_map(|p| p.into_path().ok()).map(|pb| pb.to_string_lossy().to_string()).collect();
info!("User selected {} files.", converted.len());
Json(FilesSelectionResponse {
user_cancelled: false,
selected_file_paths: paths.iter().map(|p| p.to_str().unwrap().to_string()).collect(),
selected_file_paths: converted,
})
}
@@ -185,37 +231,49 @@ pub fn select_files(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<
}
}
#[post("/save/file", data = "<payload>")]
pub fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileSaveResponse> {
pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileSaveResponse> {
// Create a new file dialog builder:
let file_dialog = FileDialogBuilder::new();
let file_dialog = MAIN_WINDOW
.lock()
.unwrap()
.as_ref()
.map(|w| w.dialog().file().set_parent(w).set_title(&payload.title));
// Set the title of the file dialog:
let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided:
let file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
let file_dialog = match &payload.name_file {
Some(previous) => {
let previous_path = previous.file_path.as_str();
file_dialog.set_directory(previous_path)
},
None => file_dialog,
let Some(mut file_dialog) = file_dialog else {
error!(Source = "Tauri"; "Cannot open save dialog: main window not available.");
return Json(FileSaveResponse {
user_cancelled: true,
save_file_path: String::from(""),
});
};
// Set the file type filter if provided:
file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
if let Some(previous) = &payload.name_file {
let previous_path = previous.file_path.as_str();
file_dialog = file_dialog.set_directory(previous_path);
}
// Displays the file dialogue box and select the file:
let file_path = file_dialog.save_file();
let file_path = file_dialog.blocking_save_file();
match file_path {
Some(path) => {
info!("User selected file for writing operation: {path:?}");
Json(FileSaveResponse {
user_cancelled: false,
save_file_path: path.to_str().unwrap().to_string(),
})
Some(path) => match path.into_path() {
Ok(pb) => {
info!("User selected file for writing operation: {pb:?}");
Json(FileSaveResponse {
user_cancelled: false,
save_file_path: pb.to_string_lossy().to_string(),
})
}
Err(e) => {
error!(Source = "Tauri"; "Failed to convert save file path: {e}");
Json(FileSaveResponse {
user_cancelled: true,
save_file_path: String::new(),
})
}
},
None => {
@@ -229,7 +287,7 @@ pub fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileS
}
/// Applies an optional file type filter to a FileDialogBuilder.
fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option<FileTypeFilter>) -> FileDialogBuilder {
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
match filter {
Some(f) => file_dialog.add_filter(
&f.filter_name,
+93 -35
View File
@@ -1,22 +1,24 @@
use std::cmp::min;
use std::convert::Infallible;
use crate::api_token::APIToken;
use crate::pandoc::PandocProcessBuilder;
use crate::pdfium::PdfiumInit;
use async_stream::stream;
use axum::extract::Query;
use axum::extract::rejection::QueryRejection;
use axum::response::sse::{Event, Sse};
use base64::{engine::general_purpose, Engine as _};
use calamine::{open_workbook_auto, Reader};
use file_format::{FileFormat, Kind};
use futures::{Stream, StreamExt};
use pdfium_render::prelude::Pdfium;
use pptx_to_md::{ImageHandlingMode, ParserConfig, PptxContainer};
use rocket::get;
use rocket::response::stream::{Event, EventStream};
use rocket::serde::Serialize;
use rocket::tokio::select;
use rocket::Shutdown;
use serde::{Deserialize, Deserializer, Serialize};
use serde::de::{Error as SerdeError, Visitor};
use std::path::Path;
use std::pin::Pin;
use log::{debug, error};
use std::fmt;
use log::{debug, error, warn};
use tokio::io::AsyncBufReadExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
@@ -82,39 +84,95 @@ const IMAGE_SEGMENT_SIZE_IN_CHARS: usize = 8_192; // equivalent to ~ 5500 token
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
type ChunkStream = Pin<Box<dyn Stream<Item = Result<Chunk>> + Send>>;
#[get("/retrieval/fs/extract?<path>&<stream_id>&<extract_images>")]
pub async fn extract_data(_token: APIToken, path: String, stream_id: String, extract_images: bool, mut end: Shutdown) -> EventStream![] {
EventStream! {
let stream_result = stream_data(&path, extract_images).await;
let id_ref = &stream_id;
match stream_result {
Ok(mut stream) => {
loop {
let chunk = select! {
chunk = stream.next() => match chunk {
Some(Ok(mut chunk)) => {
chunk.set_stream_id(id_ref);
chunk
},
Some(Err(e)) => {
yield Event::json(&format!("Error: {e}"));
break;
},
None => break,
},
_ = &mut end => break,
};
yield Event::json(&chunk);
}
},
#[derive(Deserialize)]
pub struct ExtractDataQuery {
path: String,
stream_id: String,
#[serde(deserialize_with = "deserialize_bool_case_insensitive")]
extract_images: bool,
}
Err(e) => {
yield Event::json(&format!("Error starting stream: {e}"));
fn deserialize_bool_case_insensitive<'de, D>(deserializer: D) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
struct BoolVisitor;
impl<'de> Visitor<'de> for BoolVisitor {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a boolean value")
}
fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E> {
Ok(value)
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: SerdeError,
{
match value.to_ascii_lowercase().as_str() {
"true" | "1" => Ok(true),
"false" | "0" => Ok(false),
_ => Err(E::invalid_value(serde::de::Unexpected::Str(value), &self)),
}
}
}
deserializer.deserialize_any(BoolVisitor)
}
pub async fn extract_data(
_token: APIToken,
query: std::result::Result<Query<ExtractDataQuery>, QueryRejection>,
) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {
let query = match query {
Ok(Query(query)) => Ok(query),
Err(e) => {
let message = format!("Invalid query for '/retrieval/fs/extract': {e}");
warn!("{message}");
Err(message)
},
};
let stream = stream! {
match query {
Ok(query) => {
let stream_result = stream_data(&query.path, query.extract_images).await;
let id_ref = &query.stream_id;
match stream_result {
Ok(mut stream) => {
while let Some(chunk) = stream.next().await {
match chunk {
Ok(mut chunk) => {
chunk.set_stream_id(id_ref);
yield Ok(Event::default().json_data(&chunk).unwrap_or_else(|e| Event::default().data(format!("Error: {e}"))));
},
Err(e) => {
yield Ok(Event::default().json_data(format!("Error: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error: {e}"))));
break;
},
}
}
},
Err(e) => {
yield Ok(Event::default().json_data(format!("Error starting stream: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error starting stream: {e}"))));
}
};
},
Err(e) => {
yield Ok(Event::default().json_data(format!("Error starting stream: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error starting stream: {e}"))));
},
}
};
Sse::new(stream)
}
async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStream> {
+1 -1
View File
@@ -19,4 +19,4 @@ pub mod runtime_api_token;
pub mod stale_process_cleanup;
mod sidecar_types;
pub mod tokenizer;
pub mod file_actions;
mod file_actions;
+14 -15
View File
@@ -8,9 +8,8 @@ use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle};
use flexi_logger::writers::FileLogWriter;
use log::{kv, Level};
use log::kv::{Key, Value, VisitSource};
use rocket::{get, post};
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::environment::is_dev;
@@ -34,14 +33,17 @@ pub fn init_logging() {
false => log_config.push_str("info, "),
};
// Set the log level for the Rocket library:
log_config.push_str("rocket=info, ");
// Set the log level for the Rocket server:
log_config.push_str("rocket::server=warn, ");
// Set the log level for the Reqwest library:
log_config.push_str("reqwest::async_impl::client=info");
// Keep noisy HTTP/TLS internals at info level even in development builds:
log_config.push_str("h2=info, ");
log_config.push_str("hyper=info, ");
log_config.push_str("hyper_util=info, ");
log_config.push_str("axum=info, ");
log_config.push_str("axum_server=info, ");
log_config.push_str("tower=info, ");
log_config.push_str("tower_http=info, ");
log_config.push_str("rustls=info, ");
log_config.push_str("tokio_rustls=info, ");
log_config.push_str("reqwest=info");
// Configure the initial filename. On Unix systems, the file should start
// with a dot to be hidden.
@@ -224,7 +226,6 @@ fn file_logger_format(
write!(w, "{}", &record.args())
}
#[get("/log/paths")]
pub async fn get_log_paths(_token: APIToken) -> Json<LogPathsResponse> {
Json(LogPathsResponse {
log_startup_path: LOG_STARTUP_PATH.get().expect("No startup log path was set").clone(),
@@ -269,9 +270,7 @@ fn log_with_level(
}
/// Logs an event from the .NET server.
#[post("/log/event", data = "<event>")]
pub fn log_event(_token: APIToken, event: Json<LogEvent>) -> Json<LogEventResponse> {
let event = event.into_inner();
pub async fn log_event(_token: APIToken, Json(event): Json<LogEvent>) -> Json<LogEventResponse> {
let level = parse_dotnet_log_level(&event.level);
let message = event.message.as_str();
let category = event.category.as_str();
+2 -1
View File
@@ -1,7 +1,6 @@
// Prevents an additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
extern crate rocket;
extern crate core;
use log::{info, warn};
@@ -11,6 +10,7 @@ use mindwork_ai_studio::environment::is_dev;
use mindwork_ai_studio::log::init_logging;
use mindwork_ai_studio::metadata::MetaData;
use mindwork_ai_studio::runtime_api::start_runtime_api;
use mindwork_ai_studio::secret::init_secret_store;
#[tokio::main]
async fn main() {
@@ -42,6 +42,7 @@ async fn main() {
info!("Running in production mode.");
}
init_secret_store();
generate_runtime_certificate();
start_runtime_api();
+107 -44
View File
@@ -1,13 +1,16 @@
use std::path::{Path, PathBuf};
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use log::warn;
use log::{info, warn};
use tokio::process::Command;
use crate::environment::DATA_DIRECTORY;
use crate::metadata::META_DATA;
/// Tracks whether the RID mismatch warning has been logged.
static HAS_LOGGED_RID_MISMATCH: OnceLock<()> = OnceLock::new();
static HAS_LOGGED_PANDOC_PATH: OnceLock<()> = OnceLock::new();
pub struct PandocExecutable {
pub executable: String,
@@ -114,28 +117,42 @@ impl PandocProcessBuilder {
// Any local installation should be preferred over the system-wide installation.
let data_folder = PathBuf::from(DATA_DIRECTORY.get().unwrap());
let local_installation_root_directory = data_folder.join("pandoc");
let executable_name = Self::pandoc_executable_name();
if local_installation_root_directory.exists() {
let executable_name = Self::pandoc_executable_name();
if local_installation_root_directory.exists()
&& let Ok(pandoc_path) = Self::find_executable_in_dir(&local_installation_root_directory, &executable_name) {
HAS_LOGGED_PANDOC_PATH.get_or_init(|| {
info!(Source = "PandocProcessBuilder"; "Found local Pandoc installation at: '{}'.", pandoc_path.to_string_lossy()
);
});
if let Ok(entries) = fs::read_dir(&local_installation_root_directory) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(pandoc_path) = Self::find_executable_in_dir(&path, &executable_name) {
return PandocExecutable {
executable: pandoc_path.to_string_lossy().to_string(),
is_local_installation: true,
};
}
}
}
return PandocExecutable {
executable: pandoc_path.to_string_lossy().to_string(),
is_local_installation: true,
};
}
for candidate in Self::system_pandoc_executable_candidates(&executable_name) {
if candidate.exists() && candidate.is_file() {
HAS_LOGGED_PANDOC_PATH.get_or_init(|| {
info!(Source = "PandocProcessBuilder"; "Found system Pandoc installation at: '{}'.", candidate.to_string_lossy()
);
});
return PandocExecutable {
executable: candidate.to_string_lossy().to_string(),
is_local_installation: false,
};
}
}
// When no local installation was found, we assume that the pandoc executable is in the system PATH:
HAS_LOGGED_PANDOC_PATH.get_or_init(|| {
warn!(Source = "PandocProcessBuilder"; "Falling back to system PATH for the Pandoc executable: '{}'.", executable_name);
});
PandocExecutable {
executable: Self::pandoc_executable_name(),
executable: executable_name,
is_local_installation: false,
}
}
@@ -150,10 +167,8 @@ impl PandocProcessBuilder {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(found_path) = Self::find_executable_in_dir(&path, executable_name) {
return Ok(found_path);
}
if path.is_dir() && let Ok(found_path) = Self::find_executable_in_dir(&path, executable_name) {
return Ok(found_path);
}
}
}
@@ -161,6 +176,56 @@ impl PandocProcessBuilder {
Err("Executable not found".into())
}
fn system_pandoc_executable_candidates(executable_name: &str) -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
match env::consts::OS {
"windows" => {
Self::push_env_candidate(&mut candidates, "LOCALAPPDATA", &["Pandoc", executable_name]);
Self::push_env_candidate(&mut candidates, "ProgramFiles", &["Pandoc", executable_name]);
Self::push_env_candidate(&mut candidates, "ProgramFiles(x86)", &["Pandoc", executable_name]);
},
"macos" => {
candidates.push(PathBuf::from("/opt/homebrew/bin").join(executable_name));
candidates.push(PathBuf::from("/usr/local/bin").join(executable_name));
candidates.push(PathBuf::from("/usr/bin").join(executable_name));
},
"linux" => {
candidates.push(PathBuf::from("/usr/local/bin").join(executable_name));
candidates.push(PathBuf::from("/usr/bin").join(executable_name));
candidates.push(PathBuf::from("/snap/bin").join(executable_name));
if let Some(home_dir) = env::var_os("HOME") {
candidates.push(PathBuf::from(home_dir).join(".local").join("bin").join(executable_name));
}
},
_ => {},
}
if let Some(path_value) = env::var_os("PATH") {
for path_dir in env::split_paths(&path_value) {
candidates.push(path_dir.join(executable_name));
}
}
let mut seen = HashSet::new();
candidates
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}
fn push_env_candidate(candidates: &mut Vec<PathBuf>, env_name: &str, parts: &[&str]) {
if let Some(root) = env::var_os(env_name) {
let mut path = PathBuf::from(root);
for part in parts {
path.push(part);
}
candidates.push(path);
}
}
/// Determines the executable name based on the current OS at runtime.
///
/// This uses runtime detection instead of metadata to ensure correct behavior
@@ -172,33 +237,31 @@ impl PandocProcessBuilder {
let runtime_os = std::env::consts::OS;
let runtime_arch = std::env::consts::ARCH;
if let Ok(metadata) = META_DATA.lock() {
if let Some(metadata) = metadata.as_ref() {
let metadata_arch = &metadata.architecture;
if let Ok(metadata) = META_DATA.lock() && let Some(metadata) = metadata.as_ref() {
let metadata_arch = &metadata.architecture;
// Determine expected OS from metadata:
let metadata_is_windows = metadata_arch.starts_with("win-");
let metadata_is_macos = metadata_arch.starts_with("osx-");
let metadata_is_linux = metadata_arch.starts_with("linux-");
// Determine expected OS from metadata:
let metadata_is_windows = metadata_arch.starts_with("win-");
let metadata_is_macos = metadata_arch.starts_with("osx-");
let metadata_is_linux = metadata_arch.starts_with("linux-");
// Compare with runtime OS:
let runtime_is_windows = runtime_os == "windows";
let runtime_is_macos = runtime_os == "macos";
let runtime_is_linux = runtime_os == "linux";
// Compare with runtime OS:
let runtime_is_windows = runtime_os == "windows";
let runtime_is_macos = runtime_os == "macos";
let runtime_is_linux = runtime_os == "linux";
let os_mismatch = (metadata_is_windows != runtime_is_windows)
|| (metadata_is_macos != runtime_is_macos)
|| (metadata_is_linux != runtime_is_linux);
let os_mismatch = (metadata_is_windows != runtime_is_windows)
|| (metadata_is_macos != runtime_is_macos)
|| (metadata_is_linux != runtime_is_linux);
if os_mismatch {
warn!(
Source = "Pandoc";
"Runtime-detected OS '{}-{}' differs from metadata architecture '{}'. Using runtime-detected OS. This is expected on dev machines where metadata.txt may be outdated.",
runtime_os,
runtime_arch,
metadata_arch
);
}
if os_mismatch {
warn!(
Source = "Pandoc";
"Runtime-detected OS '{}-{}' differs from metadata architecture '{}'. Using runtime-detected OS. This is expected on dev machines where metadata.txt may be outdated.",
runtime_os,
runtime_arch,
metadata_arch
);
}
}
});
+43 -25
View File
@@ -1,47 +1,65 @@
use std::error::Error;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use pdfium_render::prelude::Pdfium;
use log::{error, warn};
pub static PDFIUM_LIB_PATH: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
static PDFIUM: Lazy<Mutex<Option<Pdfium>>> = Lazy::new(|| Mutex::new(None));
pub trait PdfiumInit {
fn ai_studio_init() -> Result<Pdfium, Box<dyn std::error::Error + Send + Sync>>;
fn ai_studio_init() -> Result<Pdfium, Box<dyn Error + Send + Sync>>;
}
impl PdfiumInit for Pdfium {
/// Initializes the PDFium library for AI Studio.
fn ai_studio_init() -> Result<Pdfium, Box<dyn std::error::Error + Send + Sync>> {
let lib_path = PDFIUM_LIB_PATH.lock().unwrap();
if let Some(path) = lib_path.as_ref() {
return match Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) {
Ok(binding) => Ok(Pdfium::new(binding)),
Err(library_error) => {
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Err(system_error) => {
error!(
"Failed to load PDFium from '{path}' and the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: library error: '{library_error}'; system error: '{system_error}'."
);
fn ai_studio_init() -> Result<Pdfium, Box<dyn Error + Send + Sync>> {
let mut pdfium = PDFIUM.lock().unwrap();
if let Some(pdfium) = pdfium.as_ref() {
return Ok(pdfium.clone());
}
Err(Box::new(system_error))
}
let loaded_pdfium = load_pdfium().map_err(|error| {
Box::new(std::io::Error::other(error)) as Box<dyn Error + Send + Sync>
})?;
*pdfium = Some(loaded_pdfium.clone());
Ok(loaded_pdfium)
}
}
fn load_pdfium() -> Result<Pdfium, String> {
let lib_path = PDFIUM_LIB_PATH.lock().unwrap().clone();
if let Some(path) = lib_path.as_ref() {
return match Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) {
Ok(binding) => Ok(Pdfium::new(binding)),
Err(library_error) => {
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Err(system_error) => {
let error_message = format!(
"Failed to load PDFium from '{path}' and the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: library error: '{library_error}'; system error: '{system_error}'."
);
error!("{error_message}");
Err(error_message)
}
}
}
}
}
warn!("No custom PDFium library path set; trying to load PDFium from the system library.");
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Err(system_error) => {
error!(
"Failed to load PDFium from the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: '{system_error}'."
);
warn!("No custom PDFium library path set; trying to load PDFium from the system library.");
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Err(system_error) => {
let error_message = format!(
"Failed to load PDFium from the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: '{system_error}'."
);
Err(Box::new(system_error))
}
error!("{error_message}");
Err(error_message)
}
}
}
}
+96 -31
View File
@@ -5,20 +5,23 @@ use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use log::{debug, error, info, warn};
use once_cell::sync::Lazy;
use rocket::get;
use rocket::serde::json::Json;
use rocket::serde::Serialize;
use tauri::api::process::{Command, CommandChild, CommandEvent};
use axum::Json;
use serde::Serialize;
use crate::api_token::{APIToken};
use crate::environment::{is_dev, DATA_DIRECTORY};
use crate::certificate_factory::generate_certificate;
use std::path::PathBuf;
use tauri::PathResolver;
use tauri::Manager;
use tauri::path::BaseDirectory;
use tempfile::{TempDir, Builder};
use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process};
use crate::sidecar_types::SidecarType;
use tokio::time;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
// Qdrant server process started in a separate process and can communicate
// via HTTP or gRPC with the .NET server and the runtime process
@@ -39,14 +42,24 @@ static API_TOKEN: Lazy<APIToken> = Lazy::new(|| {
});
static TMPDIR: Lazy<Mutex<Option<TempDir>>> = Lazy::new(|| Mutex::new(None));
static QDRANT_STATUS: Lazy<Mutex<QdrantStatus>> = Lazy::new(|| Mutex::new(QdrantStatus::default()));
static QDRANT_STATUS: Lazy<Mutex<QdrantStatusInfo>> = Lazy::new(|| Mutex::new(QdrantStatusInfo::default()));
const PID_FILE_NAME: &str = "qdrant.pid";
const SIDECAR_TYPE:SidecarType = SidecarType::Qdrant;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
const STARTUP_CHECK_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
enum QdrantStatus {
#[default]
Starting,
Available,
Unavailable,
}
#[derive(Default)]
struct QdrantStatus {
is_available: bool,
struct QdrantStatusInfo {
status: QdrantStatus,
unavailable_reason: Option<String>,
}
@@ -59,6 +72,7 @@ fn qdrant_base_path() -> PathBuf {
#[derive(Serialize)]
pub struct ProvideQdrantInfo {
status: QdrantStatus,
path: String,
port_http: u16,
port_grpc: u16,
@@ -68,13 +82,14 @@ pub struct ProvideQdrantInfo {
unavailable_reason: Option<String>,
}
#[get("/system/qdrant/info")]
pub fn qdrant_port(_token: APIToken) -> Json<ProvideQdrantInfo> {
pub async fn qdrant_port(_token: APIToken) -> Json<ProvideQdrantInfo> {
let status = QDRANT_STATUS.lock().unwrap();
let is_available = status.is_available;
let current_status = status.status;
let is_available = current_status == QdrantStatus::Available;
let unavailable_reason = status.unavailable_reason.clone();
Json(ProvideQdrantInfo {
status: current_status,
path: if is_available {
qdrant_base_path().to_string_lossy().to_string()
} else {
@@ -98,14 +113,20 @@ pub fn qdrant_port(_token: APIToken) -> Json<ProvideQdrantInfo> {
}
/// Starts the Qdrant server in a separate process.
pub fn start_qdrant_server(path_resolver: PathResolver){
pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
set_qdrant_starting();
tauri::async_runtime::spawn(async move {
cleanup_qdrant();
start_qdrant_server_internal(app_handle);
});
}
fn start_qdrant_server_internal<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
let path = qdrant_base_path();
if !path.exists() {
if let Err(e) = fs::create_dir_all(&path){
error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e);
set_qdrant_unavailable(format!("The Qdrant data directory could not be created: {e}"));
return;
};
if !path.exists() && let Err(e) = fs::create_dir_all(&path){
error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e);
set_qdrant_unavailable(format!("The Qdrant data directory could not be created: {e}"));
return;
}
let (cert_path, key_path) = match create_temp_tls_files(&path) {
@@ -119,12 +140,13 @@ pub fn start_qdrant_server(path_resolver: PathResolver){
let storage_path = path.join("storage").to_string_lossy().to_string();
let snapshot_path = path.join("snapshots").to_string_lossy().to_string();
let init_path = path.join(".qdrant-initialized").to_string_lossy().to_string();
let init_path = path.join(".qdrant-initialized");
let init_path_environment = init_path.to_string_lossy().to_string();
let qdrant_server_environment = HashMap::from_iter([
let qdrant_server_environment: HashMap<String, String> = HashMap::from_iter([
(String::from("QDRANT__SERVICE__HTTP_PORT"), QDRANT_SERVER_PORT_HTTP.to_string()),
(String::from("QDRANT__SERVICE__GRPC_PORT"), QDRANT_SERVER_PORT_GRPC.to_string()),
(String::from("QDRANT_INIT_FILE_PATH"), init_path),
(String::from("QDRANT_INIT_FILE_PATH"), init_path_environment),
(String::from("QDRANT__STORAGE__STORAGE_PATH"), storage_path),
(String::from("QDRANT__STORAGE__SNAPSHOTS_PATH"), snapshot_path),
(String::from("QDRANT__TLS__CERT"), cert_path.to_string_lossy().to_string()),
@@ -135,9 +157,9 @@ pub fn start_qdrant_server(path_resolver: PathResolver){
let server_spawn_clone = QDRANT_SERVER.clone();
let qdrant_relative_source_path = "resources/databases/qdrant/config.yaml";
let qdrant_source_path = match path_resolver.resolve_resource(qdrant_relative_source_path) {
Some(path) => path,
None => {
let qdrant_source_path = match app_handle.path().resolve(qdrant_relative_source_path, BaseDirectory::Resource) {
Ok(path) => path,
Err(_) => {
let reason = format!("The Qdrant config resource '{qdrant_relative_source_path}' could not be resolved.");
error!(Source = "Qdrant"; "{reason} Starting the app without Qdrant.");
set_qdrant_unavailable(reason);
@@ -147,7 +169,9 @@ pub fn start_qdrant_server(path_resolver: PathResolver){
let qdrant_source_path_display = qdrant_source_path.to_string_lossy().to_string();
tauri::async_runtime::spawn(async move {
let sidecar = match Command::new_sidecar("qdrant") {
let shell = app_handle.shell();
let sidecar = match shell.sidecar("qdrant") {
Ok(sidecar) => sidecar,
Err(e) => {
let reason = format!("Failed to create sidecar for Qdrant: {e}");
@@ -172,18 +196,30 @@ pub fn start_qdrant_server(path_resolver: PathResolver){
};
let server_pid = child.pid();
set_qdrant_available();
info!(Source = "Bootloader Qdrant"; "Qdrant server process started with PID={server_pid}.");
log_potential_stale_process(path.join(PID_FILE_NAME), server_pid, SIDECAR_TYPE);
// Save the server process to stop it later:
*server_spawn_clone.lock().unwrap() = Some(child);
let init_path_clone = init_path.clone();
tauri::async_runtime::spawn(async move {
if wait_for_qdrant_startup(init_path_clone).await {
set_qdrant_available();
info!(Source = "Qdrant"; "Qdrant is available.");
} else {
let reason = "Qdrant did not become available within the startup timeout.".to_string();
error!(Source = "Qdrant"; "{reason}");
set_qdrant_unavailable(reason);
}
});
// Log the output of the Qdrant server:
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
let line = line.trim_end();
let line_utf8 = String::from_utf8_lossy(&line).to_string();
let line = line_utf8.trim_end();
if line.contains("INFO") || line.contains("info") {
info!(Source = "Qdrant Server"; "{line}");
} else if line.contains("WARN") || line.contains("warning") {
@@ -196,12 +232,21 @@ pub fn start_qdrant_server(path_resolver: PathResolver){
},
CommandEvent::Stderr(line) => {
error!(Source = "Qdrant Server (stderr)"; "{line}");
let line_utf8 = String::from_utf8_lossy(&line).to_string();
error!(Source = "Qdrant Server (stderr)"; "{line_utf8}");
},
_ => {}
}
}
let is_available = QDRANT_STATUS.lock().unwrap().status == QdrantStatus::Available;
let unavailable_reason = if is_available {
"Qdrant server process stopped.".to_string()
} else {
"Qdrant server process stopped before it became available.".to_string()
};
set_qdrant_unavailable(unavailable_reason);
});
}
@@ -224,6 +269,20 @@ pub fn stop_qdrant_server() {
cleanup_qdrant();
}
async fn wait_for_qdrant_startup(init_path: PathBuf) -> bool {
let mut elapsed = Duration::ZERO;
while elapsed < STARTUP_TIMEOUT {
if init_path.exists() {
return true;
}
time::sleep(STARTUP_CHECK_INTERVAL).await;
elapsed += STARTUP_CHECK_INTERVAL;
}
false
}
/// Create a temporary directory with TLS relevant files
pub fn create_temp_tls_files(path: &PathBuf) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
let cert = generate_certificate();
@@ -276,13 +335,19 @@ pub fn cleanup_qdrant() {
fn set_qdrant_available() {
let mut status = QDRANT_STATUS.lock().unwrap();
status.is_available = true;
status.status = QdrantStatus::Available;
status.unavailable_reason = None;
}
fn set_qdrant_starting() {
let mut status = QDRANT_STATUS.lock().unwrap();
status.status = QdrantStatus::Starting;
status.unavailable_reason = None;
}
fn set_qdrant_unavailable(reason: String) {
let mut status = QDRANT_STATUS.lock().unwrap();
status.is_available = false;
status.status = QdrantStatus::Unavailable;
status.unavailable_reason = Some(reason);
}
+53 -107
View File
@@ -1,12 +1,16 @@
use log::info;
use once_cell::sync::Lazy;
use rocket::config::Shutdown;
use rocket::figment::Figment;
use rocket::routes;
use axum::routing::{get, post};
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
use std::sync::Once;
use crate::runtime_certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY};
use crate::environment::is_dev;
use crate::network::get_available_port;
static RUSTLS_CRYPTO_PROVIDER_INIT: Once = Once::new();
/// The port used for the runtime API server. In the development environment, we use a fixed
/// port, in the production environment we use the next available port. This differentiation
/// is necessary because we cannot communicate the port to the .NET server in the development
@@ -24,114 +28,56 @@ pub static API_SERVER_PORT: Lazy<u16> = Lazy::new(|| {
pub fn start_runtime_api() {
let api_port = *API_SERVER_PORT;
info!("Try to start the API server on 'http://localhost:{api_port}'...");
// Get the shutdown configuration:
let shutdown = create_shutdown();
// Configure the runtime API server:
let figment = Figment::from(rocket::Config::release_default())
let app = Router::new()
.route("/system/dotnet/port", get(crate::dotnet::dotnet_port))
.route("/system/dotnet/ready", get(crate::dotnet::dotnet_ready))
.route("/system/qdrant/info", get(crate::qdrant::qdrant_port))
.route("/clipboard/set", post(crate::clipboard::set_clipboard))
.route("/events", get(crate::app_window::get_event_stream))
.route("/updates/check", get(crate::app_window::check_for_update))
.route("/updates/install", get(crate::app_window::install_update))
.route("/app/exit", post(crate::app_window::exit_app))
.route("/select/directory", post(crate::file_actions::select_directory))
.route("/select/file", post(crate::file_actions::select_file))
.route("/select/files", post(crate::file_actions::select_files))
.route("/save/file", post(crate::file_actions::save_file))
.route("/secrets/get", post(crate::secret::get_secret))
.route("/secrets/store", post(crate::secret::store_secret))
.route("/secrets/delete", post(crate::secret::delete_secret))
.route("/system/directories/config", get(crate::environment::get_config_directory))
.route("/system/directories/data", get(crate::environment::get_data_directory))
.route("/system/language", get(crate::environment::read_user_language))
.route("/system/username", get(crate::environment::read_user_name))
.route("/system/enterprise/config/id", get(crate::environment::read_enterprise_env_config_id))
.route("/system/enterprise/config/server", get(crate::environment::read_enterprise_env_config_server_url))
.route("/system/enterprise/config/encryption_secret", get(crate::environment::read_enterprise_env_config_encryption_secret))
.route("/system/enterprise/configs", get(crate::environment::read_enterprise_configs))
.route("/retrieval/fs/extract", get(crate::file_data::extract_data))
.route("/log/paths", get(crate::log::get_log_paths))
.route("/log/event", post(crate::log::log_event))
.route("/shortcuts/register", post(crate::app_window::register_shortcut))
.route("/shortcuts/validate", post(crate::app_window::validate_shortcut))
.route("/shortcuts/suspend", post(crate::app_window::suspend_shortcuts))
.route("/shortcuts/resume", post(crate::app_window::resume_shortcuts));
// We use the next available port which was determined before:
.merge(("port", api_port))
// The runtime API server should be accessible only from the local machine:
.merge(("address", "127.0.0.1"))
// We do not want to use the Ctrl+C signal to stop the server:
.merge(("ctrlc", false))
// Set a name for the server:
.merge(("ident", "AI Studio Runtime API"))
// Set the maximum number of workers and blocking threads:
.merge(("workers", 3))
.merge(("max_blocking", 12))
// No colors and emojis in the log output:
.merge(("cli_colors", false))
// Read the TLS certificate and key from the generated certificate data in-memory:
.merge(("tls.certs", CERTIFICATE.get().unwrap()))
.merge(("tls.key", CERTIFICATE_PRIVATE_KEY.get().unwrap()))
// Set the shutdown configuration:
.merge(("shutdown", shutdown));
//
// Start the runtime API server in a separate thread. This is necessary
// because the server is blocking, and we need to run the Tauri app in
// parallel:
//
tauri::async_runtime::spawn(async move {
rocket::custom(figment)
.mount("/", routes![
crate::dotnet::dotnet_port,
crate::dotnet::dotnet_ready,
crate::qdrant::qdrant_port,
crate::clipboard::set_clipboard,
crate::app_window::get_event_stream,
crate::app_window::check_for_update,
crate::app_window::install_update,
crate::file_actions::select_directory,
crate::file_actions::select_file,
crate::file_actions::select_files,
crate::file_actions::save_file,
crate::app_window::exit_app,
crate::secret::get_secret,
crate::secret::store_secret,
crate::secret::delete_secret,
crate::environment::get_data_directory,
crate::environment::get_config_directory,
crate::environment::read_user_language,
crate::environment::read_enterprise_env_config_id,
crate::environment::read_enterprise_env_config_server_url,
crate::environment::read_enterprise_env_config_encryption_secret,
crate::environment::read_enterprise_configs,
crate::file_data::extract_data,
crate::log::get_log_paths,
crate::log::log_event,
crate::tokenizer::token_count,
crate::tokenizer::validate_tokenizer,
crate::tokenizer::store_tokenizer,
crate::tokenizer::delete_tokenizer,
crate::tokenizer::set_tokenizer,
crate::app_window::register_shortcut,
crate::app_window::validate_shortcut,
crate::app_window::suspend_shortcuts,
crate::app_window::resume_shortcuts,
])
.ignite().await.unwrap()
.launch().await.unwrap();
install_rustls_crypto_provider();
let cert = CERTIFICATE.get().unwrap().clone();
let key = CERTIFICATE_PRIVATE_KEY.get().unwrap().clone();
let tls_config = RustlsConfig::from_pem(cert, key).await.unwrap();
let addr = SocketAddr::from(([127, 0, 0, 1], api_port));
axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service())
.await
.unwrap();
});
}
fn create_shutdown() -> Shutdown {
//
// Create a shutdown configuration, depending on the operating system:
//
#[cfg(unix)]
{
use std::collections::HashSet;
let mut shutdown = Shutdown {
// We do not want to use the Ctrl+C signal to stop the server:
ctrlc: false,
// Everything else is set to default for now:
..Shutdown::default()
};
shutdown.signals = HashSet::new();
shutdown
}
#[cfg(windows)]
{
Shutdown {
// We do not want to use the Ctrl+C signal to stop the server:
ctrlc: false,
// Everything else is set to default for now:
..Shutdown::default()
}
}
fn install_rustls_crypto_provider() {
RUSTLS_CRYPTO_PROVIDER_INIT.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
}
+14 -18
View File
@@ -1,33 +1,29 @@
use once_cell::sync::Lazy;
use rocket::http::Status;
use rocket::Request;
use rocket::request::FromRequest;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use crate::api_token::{generate_api_token, APIToken};
pub static API_TOKEN: Lazy<APIToken> = Lazy::new(|| generate_api_token());
pub static API_TOKEN: Lazy<APIToken> = Lazy::new(generate_api_token);
/// The request outcome type used to handle API token requests.
type RequestOutcome<R, T> = rocket::request::Outcome<R, T>;
impl<S> FromRequestParts<S> for APIToken
where
S: Send + Sync,
{
type Rejection = StatusCode;
/// The request outcome implementation for the API token.
#[rocket::async_trait]
impl<'r> FromRequest<'r> for APIToken {
type Error = APITokenError;
/// Handles the API token requests.
async fn from_request(request: &'r Request<'_>) -> RequestOutcome<Self, Self::Error> {
let token = request.headers().get_one("token");
match token {
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
match parts.headers.get("token").and_then(|value| value.to_str().ok()) {
Some(token) => {
let received_token = APIToken::from_hex_text(token);
if API_TOKEN.validate(&received_token) {
RequestOutcome::Success(received_token)
Ok(received_token)
} else {
RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid))
Err(StatusCode::UNAUTHORIZED)
}
}
None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)),
None => Err(StatusCode::UNAUTHORIZED),
}
}
}
+74 -14
View File
@@ -1,15 +1,45 @@
use keyring::Entry;
use axum::Json;
use keyring_core::{Entry, Error as KeyringError};
use log::{error, info, warn};
use rocket::post;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use keyring::error::Error::NoEntry;
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// Initializes the native credential store used by keyring-core.
pub fn init_secret_store() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
match apple_native_keyring_store::keychain::Store::new() {
Ok(store) => {
keyring_core::set_default_store(store);
info!(Source = "Secret Store"; "Initialized the macOS Keychain credential store.");
},
Err(e) => error!(Source = "Secret Store"; "Failed to initialize the macOS Keychain credential store: {e}."),
}
} else if #[cfg(target_os = "windows")] {
match windows_native_keyring_store::Store::new() {
Ok(store) => {
keyring_core::set_default_store(store);
info!(Source = "Secret Store"; "Initialized the Windows Credential Manager store.");
},
Err(e) => error!(Source = "Secret Store"; "Failed to initialize the Windows Credential Manager store: {e}."),
}
} else if #[cfg(target_os = "linux")] {
match dbus_secret_service_keyring_store::Store::new() {
Ok(store) => {
keyring_core::set_default_store(store);
info!(Source = "Secret Store"; "Initialized the DBus Secret Service credential store.");
},
Err(e) => error!(Source = "Secret Store"; "Failed to initialize the DBus Secret Service credential store: {e}."),
}
} else {
warn!(Source = "Secret Store"; "No native credential store is configured for this platform.");
}
}
}
/// Stores a secret in the secret store using the operating system's keyring.
#[post("/secrets/store", data = "<request>")]
pub fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<StoreSecretResponse> {
pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<StoreSecretResponse> {
let user_name = request.user_name.as_str();
let decrypted_text = match ENCRYPTION.decrypt(&request.secret) {
Ok(text) => text,
@@ -23,7 +53,16 @@ pub fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<StoreS
};
let service = format!("mindwork-ai-studio::{}", request.destination);
let entry = Entry::new(service.as_str(), user_name).unwrap();
let entry = match Entry::new(service.as_str(), user_name) {
Ok(entry) => entry,
Err(e) => {
error!(Source = "Secret Store"; "Failed to create secret entry for {service} and user {user_name}: {e}.");
return Json(StoreSecretResponse {
success: false,
issue: e.to_string(),
});
},
};
let result = entry.set_password(decrypted_text.as_str());
match result {
Ok(_) => {
@@ -60,11 +99,23 @@ pub struct StoreSecretResponse {
}
/// Retrieves a secret from the secret store using the operating system's keyring.
#[post("/secrets/get", data = "<request>")]
pub fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<RequestedSecret> {
pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<RequestedSecret> {
let user_name = request.user_name.as_str();
let service = format!("mindwork-ai-studio::{}", request.destination);
let entry = Entry::new(service.as_str(), user_name).unwrap();
let entry = match Entry::new(service.as_str(), user_name) {
Ok(entry) => entry,
Err(e) => {
if !request.is_trying {
error!(Source = "Secret Store"; "Failed to create secret entry for '{service}' and user '{user_name}': {e}.");
}
return Json(RequestedSecret {
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to create secret entry for '{service}' and user '{user_name}': {e}"),
});
},
};
let secret = entry.get_password();
match secret {
Ok(s) => {
@@ -121,11 +172,20 @@ pub struct RequestedSecret {
}
/// Deletes a secret from the secret store using the operating system's keyring.
#[post("/secrets/delete", data = "<request>")]
pub fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<DeleteSecretResponse> {
pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<DeleteSecretResponse> {
let user_name = request.user_name.as_str();
let service = format!("mindwork-ai-studio::{}", request.destination);
let entry = Entry::new(service.as_str(), user_name).unwrap();
let entry = match Entry::new(service.as_str(), user_name) {
Ok(entry) => entry,
Err(e) => {
error!(Source = "Secret Store"; "Failed to create secret entry for {service} and user {user_name}: {e}.");
return Json(DeleteSecretResponse {
success: false,
was_entry_found: false,
issue: e.to_string(),
});
},
};
let result = entry.delete_credential();
match result {
@@ -138,7 +198,7 @@ pub fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<Del
})
},
Err(NoEntry) => {
Err(KeyringError::NoEntry) => {
warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found.");
Json(DeleteSecretResponse {
success: true,
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn kill_stale_process(pid_file_path: PathBuf, sidecar_type: SidecarType) ->
let killed = process.kill_with(Signal::Kill).unwrap_or_else(|| process.kill());
if !killed {
return Err(Error::new(ErrorKind::Other, "Failed to kill process"));
return Err(Error::other("Failed to kill process"));
}
info!(Source="Stale Process Cleanup";"{}: Killed process: \"{}\"", sidecar_type,pid_file_path.display());
} else {
+58 -78
View File
@@ -1,44 +1,62 @@
{
"productName": "MindWork AI Studio",
"mainBinaryName": "MindWork AI Studio",
"version": "26.5.5",
"identifier": "com.github.mindwork-ai.ai-studio",
"build": {
"devPath": "ui/",
"distDir": "ui/",
"withGlobalTauri": false
"frontendDist": "ui/"
},
"package": {
"productName": "MindWork AI Studio",
"version": "26.4.1"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"sidecar": true,
"all": false,
"open": true,
"scope": [
{
"name": "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer",
"sidecar": true,
"args": true
},
{
"name": "target/databases/qdrant/qdrant",
"sidecar": true,
"args": true
}
]
},
"http" : {
"all": true,
"request": true,
"scope": [
"http://localhost"
]
},
"fs": {
"scope": ["$RESOURCE/resources/*"]
"bundle": {
"active": true,
"targets": [
"appimage",
"app",
"dmg",
"nsis"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer",
"target/databases/qdrant/qdrant"
],
"resources": [
"resources/databases/qdrant/config.yaml",
"resources/libraries/*"
],
"macOS": {
"exceptionDomain": "localhost"
},
"linux": {
"appimage": {
"bundleMediaFramework": true
}
},
"createUpdaterArtifacts": "v1Compatible"
},
"plugins": {
"updater": {
"windows": {
"installMode": "passive"
},
"endpoints": [
"https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM3MzE4MTM4RTNDMkM0NEQKUldSTnhNTGpPSUV4TjFkczFxRFJOZWgydzFQN1dmaFlKbXhJS1YyR1RKS1RnR09jYUpMaGsrWXYK"
}
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"fullscreen": false,
@@ -46,51 +64,13 @@
"title": "MindWork AI Studio",
"width": 1920,
"height": 1080,
"fileDropEnabled": true
"dragDropEnabled": true,
"useHttpsScheme": true
}
],
"security": {
"csp": null,
"dangerousRemoteDomainIpcAccess": [
{
"domain": "localhost",
"windows": ["main"],
"enableTauriAPI": true
}
]
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.github.mindwork-ai.ai-studio",
"externalBin": [
"../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer",
"target/databases/qdrant/qdrant"
],
"resources": [
"resources/**"
],
"macOS": {
"exceptionDomain": "localhost"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"updater": {
"active": true,
"endpoints": [
"https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json"
],
"dialog": false,
"windows": {
"installMode": "passive"
},
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM3MzE4MTM4RTNDMkM0NEQKUldSTnhNTGpPSUV4TjFkczFxRFJOZWgydzFQN1dmaFlKbXhJS1YyR1RKS1RnR09jYUpMaGsrWXYK"
"csp": null
}
}
}