mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-06 05:09:40 +00:00
Merge branch 'main' into chunk-data
This commit is contained in:
commit
00b6c98f3b
167 files changed
+8153
-3577
No files matched your search
Generated
+2236
-2086
File diff suppressed because it is too large.
Load diff
+35
-30
@@ -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
@@ -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("ed[..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("ed[..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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(¤t_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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user