Merge branch 'main' into chunk-data

This commit is contained in:
PaulKoudelka committed 2026-08-04 18:01:25 +02:00
commit 9932b5870b
484 files changed
+45549 -2467

No files matched your search

+201 -243
View File
@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -13,25 +12,34 @@ use log::{debug, error, info, trace, warn};
use once_cell::sync::Lazy;
use pdfium_render::prelude::Pdfium;
use serde::{Deserialize, Serialize};
use strum_macros::Display;
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context};
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
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;
use crate::clipboard::shutdown_clipboard;
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY};
use crate::environment::{
is_prod, is_dev, is_flatpak, CONFIG_DIRECTORY, DATA_DIRECTORY, FLATPAK_LIBRARY_DIRECTORY,
};
use crate::log::switch_to_file_logging;
use crate::pdfium::PDFIUM_LIB_PATH;
use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database};
use crate::global_shortcuts::{RegisterShortcutRequest, ShortcutResponse};
#[cfg(debug_assertions)]
use crate::dotnet::create_startup_env_file;
use crate::tokenizer::set_default_tokenizer_path;
#[cfg(target_os = "linux")]
use webkit2gtk::glib::Cast;
#[cfg(target_os = "linux")]
use webkit2gtk::{PermissionRequestExt, UserMediaPermissionRequestExt};
/// The Tauri main window.
pub static MAIN_WINDOW: Lazy<Mutex<Option<WebviewWindow>>> = Lazy::new(|| Mutex::new(None));
@@ -41,22 +49,11 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::
/// The event broadcast sender for Tauri events.
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
/// 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")]
pub enum Shortcut {
None = 0,
VoiceRecordingToggle,
}
/// Starts the Tauri app.
pub fn start_tauri() {
pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
info!("Starting Tauri app...");
// Create the event broadcast channel:
@@ -137,6 +134,9 @@ pub fn start_tauri() {
// Get the main window:
let window = app.get_webview_window("main").expect("Failed to get main window.");
#[cfg(target_os = "linux")]
register_linux_permission_request_handler(&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
// are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338):
@@ -182,7 +182,7 @@ pub fn start_tauri() {
Ok(())
})
.plugin(tauri_plugin_window_state::Builder::default().build())
.build(generate_context!())
.build(tauri_context)
.expect("Error while running Tauri application");
// The app event handler:
@@ -208,6 +208,7 @@ pub fn start_tauri() {
RunEvent::ExitRequested { .. } => {
warn!(Source = "Tauri"; "Run event: exit was requested.");
shutdown_clipboard();
stop_qdrant_edge_database();
if is_prod() {
warn!("Try to stop the .NET server as well...");
@@ -248,6 +249,69 @@ fn same_origin(left: &tauri::Url, right: &tauri::Url) -> bool {
&& left.port_or_known_default() == right.port_or_known_default()
}
#[cfg(any(target_os = "linux", test))]
fn should_allow_audio_capture(
approved_app_url: Option<&tauri::Url>,
current_webview_url: Option<&tauri::Url>,
requests_audio: bool,
requests_video: bool,
) -> bool {
requests_audio
&& !requests_video
&& approved_app_url.is_some_and(is_local_http_url)
&& approved_app_url
.zip(current_webview_url)
.is_some_and(|(approved, current)| same_origin(approved, current))
}
#[cfg(target_os = "linux")]
fn register_linux_permission_request_handler(window: &WebviewWindow) {
if let Err(error) = window.with_webview(|platform_webview| {
use webkit2gtk::WebViewExt;
use webkit2gtk::UserMediaPermissionRequest;
let webview = platform_webview.inner();
webview.connect_permission_request(|webview, request| {
let Some(user_media_request) = request.downcast_ref::<UserMediaPermissionRequest>() else {
request.deny();
info!(Source = "Tauri"; "Denied a non-user-media WebKit permission request.");
return true;
};
let current_webview_url = webview
.uri()
.and_then(|uri| tauri::Url::parse(uri.as_str()).ok());
let approved_app_url = APPROVED_APP_URL.lock().unwrap().clone();
let origin_matches = approved_app_url
.as_ref()
.zip(current_webview_url.as_ref())
.is_some_and(|(approved, current)| same_origin(approved, current));
let requests_audio = user_media_request.is_for_audio_device();
let requests_video = user_media_request.is_for_video_device();
let allow = should_allow_audio_capture(
approved_app_url.as_ref(),
current_webview_url.as_ref(),
requests_audio,
requests_video,
);
if allow {
request.allow();
} else {
request.deny();
}
info!(
Source = "Tauri";
"Handled WebKit user-media permission request: allowed={allow}, origin_matches={origin_matches}, audio={requests_audio}, video={requests_video}."
);
true
});
}) {
error!(Source = "Tauri"; "Failed to register the Linux WebKit permission request handler: {error}");
}
}
fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>, url: &tauri::Url) -> bool {
match url.scheme() {
"mailto" | "tel" => return true,
@@ -412,6 +476,7 @@ pub enum TauriEventType {
FileDropCanceled,
GlobalShortcutPressed,
GlobalShortcutChanged,
}
/// Changes the location of the main window to the given URL.
@@ -452,8 +517,9 @@ pub async fn change_location_to(url: &str) {
/// Checks for updates.
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.");
if !self_update_allowed(is_dev(), is_flatpak()) {
let reason = if is_flatpak() { "Flatpak installations are updated externally" } else { "the app is running in development mode" };
warn!(Source = "Updater"; "Skipping update check because {reason}.");
return Json(CheckUpdateResponse {
update_is_available: false,
error: false,
@@ -537,8 +603,9 @@ pub struct CheckUpdateResponse {
/// Installs the update.
pub async fn install_update(_token: APIToken) {
if is_dev() {
warn!(Source = "Updater"; "The app is running in development mode; skipping update installation.");
if !self_update_allowed(is_dev(), is_flatpak()) {
let reason = if is_flatpak() { "Flatpak installations are updated externally" } else { "the app is running in development mode" };
warn!(Source = "Updater"; "Skipping update installation because {reason}.");
return;
}
@@ -596,22 +663,8 @@ pub async fn install_update(_token: APIToken) {
}
}
/// Request payload for registering a global shortcut.
#[derive(Clone, Deserialize)]
pub struct RegisterShortcutRequest {
/// The shortcut ID to use.
id: Shortcut,
/// The shortcut string in Tauri format (e.g., "CmdOrControl+1").
/// Use empty string to unregister the shortcut.
shortcut: String,
}
/// Response for shortcut registration.
#[derive(Serialize)]
pub struct ShortcutResponse {
success: bool,
error_message: String,
fn self_update_allowed(development: bool, flatpak: bool) -> bool {
!development && !flatpak
}
/// Response for application exit requests.
@@ -621,28 +674,6 @@ pub struct AppExitResponse {
error_message: String,
}
/// 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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
shortcut: &str,
shortcut_id: Shortcut,
event_sender: broadcast::Sender<Event>,
) -> 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 {
if let Err(error) = sender.send(event) {
error!(Source = "Tauri"; "Failed to send global shortcut event: {error}");
}
});
})
}
/// Requests a controlled shutdown of the entire desktop application.
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
let app_handle = {
@@ -674,89 +705,9 @@ pub async 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.
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
let id = payload.id;
let new_shortcut = payload.shortcut.clone();
if id == Shortcut::None {
error!(Source = "Tauri"; "Cannot register NONE shortcut.");
return Json(ShortcutResponse {
success: false,
error_message: "Cannot register NONE shortcut".to_string(),
});
}
info!(Source = "Tauri"; "Registering global shortcut '{}' with key '{new_shortcut}'.", id);
// 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() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot register shortcut: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
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) && !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}"),
}
}
// When the new shortcut is empty, we're done (just unregistering):
if new_shortcut.is_empty() {
registered_shortcuts.remove(&id);
info!(Source = "Tauri"; "Shortcut '{}' has been disabled.", id);
return Json(ShortcutResponse {
success: true,
error_message: String::new(),
});
}
// Get the event broadcast sender for the shortcut callback:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
let event_sender = match event_broadcast_lock.as_ref() {
Some(sender) => sender.clone(),
None => {
error!(Source = "Tauri"; "Cannot register shortcut: event broadcast not initialized.");
return Json(ShortcutResponse {
success: false,
error_message: "Event broadcast not initialized".to_string(),
});
}
};
drop(event_broadcast_lock);
// Register the new shortcut:
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);
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
},
Err(error) => {
let error_msg = format!("Failed to register shortcut: {error}");
error!(Source = "Tauri"; "{error_msg}");
Json(ShortcutResponse {
success: false,
error_message: error_msg,
})
}
}
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await)
}
/// Request payload for validating a shortcut.
@@ -792,8 +743,7 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
}
// Check if the shortcut is already registered:
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
for (name, registered_shortcut) in registered_shortcuts.iter() {
for (name, registered_shortcut) in crate::global_shortcuts::registered_shortcuts().await {
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
return Json(ShortcutValidationResponse {
is_valid: true,
@@ -804,8 +754,6 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
}
}
drop(registered_shortcuts);
// Try to parse the shortcut to validate syntax.
// We can't easily validate without registering in Tauri 1.x,
// so we do basic syntax validation here:
@@ -828,100 +776,20 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
}
}
/// Suspends shortcut processing by unregistering all shortcuts from the OS.
/// The shortcuts remain in our internal map, so they can be re-registered on resume.
/// Suspends shortcut processing. Portal sessions remain active and ignore activations;
/// Tauri shortcuts are temporarily unregistered and restored 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.
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() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot suspend shortcuts: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
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):
for (name, shortcut) in registered_shortcuts.iter() {
if !shortcut.is_empty() {
match shortcut_manager.unregister(shortcut.as_str()) {
Ok(_) => info!(Source = "Tauri"; "Temporarily unregistered shortcut '{shortcut}' for '{}'.", name),
Err(error) => warn!(Source = "Tauri"; "Failed to unregister shortcut '{shortcut}' for '{}': {error}", name),
}
}
}
info!(Source = "Tauri"; "Shortcut processing has been suspended ({} shortcuts unregistered).", registered_shortcuts.len());
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
Json(crate::global_shortcuts::suspend(app_handle).await)
}
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
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() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot resume shortcuts: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let app_handle = main_window.app_handle();
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Get the event broadcast sender for the shortcut callbacks:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
let event_sender = match event_broadcast_lock.as_ref() {
Some(sender) => sender.clone(),
None => {
error!(Source = "Tauri"; "Cannot resume shortcuts: event broadcast not initialized.");
return Json(ShortcutResponse {
success: false,
error_message: "Event broadcast not initialized".to_string(),
});
}
};
drop(event_broadcast_lock);
// Re-register all shortcuts with the OS:
let mut success_count = 0;
for (shortcut_id, shortcut) in registered_shortcuts.iter() {
if shortcut.is_empty() {
continue;
}
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;
},
Err(error) => warn!(Source = "Tauri"; "Failed to re-register shortcut '{shortcut}' for '{}': {error}", shortcut_id),
}
}
info!(Source = "Tauri"; "Shortcut processing has been resumed ({success_count} shortcuts re-registered).");
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::resume(app_handle, event_sender).await)
}
/// Validates the syntax of a shortcut string.
@@ -977,7 +845,7 @@ fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
}
};
match select_pdfium_library_directory(&resource_dir) {
match select_pdfium_library_directory(&resource_dir, is_flatpak()) {
Some(path) => {
*PDFIUM_LIB_PATH.lock().unwrap() = Some(path.to_string_lossy().to_string());
}
@@ -987,11 +855,23 @@ fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
}
}
fn select_pdfium_library_directory(resource_dir: &Path) -> Option<PathBuf> {
let candidate_paths = [
resource_dir.join("resources").join("libraries"),
resource_dir.join("libraries"),
];
fn select_pdfium_library_directory(resource_dir: &Path, include_flatpak_library_directory: bool) -> Option<PathBuf> {
select_pdfium_library_directory_for(resource_dir, include_flatpak_library_directory, Path::new(FLATPAK_LIBRARY_DIRECTORY))
}
fn select_pdfium_library_directory_for(
resource_dir: &Path,
include_flatpak_library_directory: bool,
flatpak_library_directory: &Path,
) -> Option<PathBuf> {
let mut candidate_paths = Vec::new();
if include_flatpak_library_directory {
candidate_paths.push(flatpak_library_directory.to_path_buf());
}
candidate_paths.push(resource_dir.join("resources").join("libraries"));
candidate_paths.push(resource_dir.join("libraries"));
for path in candidate_paths {
let pdfium_library_path = Pdfium::pdfium_platform_library_name_at_path(&path);
@@ -1016,6 +896,20 @@ mod tests {
use super::*;
use std::fs;
#[test]
fn self_update_is_disabled_in_development() {
assert!(!self_update_allowed(true, false));
}
#[test]
fn self_update_is_disabled_for_flatpak() {
assert!(!self_update_allowed(false, true));
}
#[test]
fn self_update_is_enabled_for_normal_production_installations() {
assert!(self_update_allowed(false, false));
}
#[test]
fn pdfium_library_directory_prefers_resources_libraries() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -1025,7 +919,7 @@ mod tests {
create_pdfium_library_in(&libraries);
assert_eq!(
select_pdfium_library_directory(temp_dir.path()),
select_pdfium_library_directory(temp_dir.path(), false),
Some(resources_libraries)
);
}
@@ -1039,7 +933,7 @@ mod tests {
create_pdfium_library_in(&libraries);
assert_eq!(
select_pdfium_library_directory(temp_dir.path()),
select_pdfium_library_directory(temp_dir.path(), false),
Some(libraries)
);
}
@@ -1050,7 +944,33 @@ mod tests {
fs::create_dir_all(temp_dir.path().join("resources").join("libraries")).unwrap();
fs::create_dir_all(temp_dir.path().join("libraries")).unwrap();
assert_eq!(select_pdfium_library_directory(temp_dir.path()), None);
assert_eq!(select_pdfium_library_directory(temp_dir.path(), false), None);
}
#[test]
fn pdfium_library_directory_prefers_flatpak_library_directory_when_flatpak() {
let temp_dir = tempfile::tempdir().unwrap();
let flatpak_library_directory = temp_dir.path().join("app").join("lib");
let resources_libraries = temp_dir.path().join("resources").join("libraries");
create_pdfium_library_in(&flatpak_library_directory);
create_pdfium_library_in(&resources_libraries);
assert_eq!(
select_pdfium_library_directory_for(temp_dir.path(), true, &flatpak_library_directory),
Some(flatpak_library_directory)
);
}
#[test]
fn pdfium_library_directory_skips_flatpak_library_directory_when_not_flatpak() {
let temp_dir = tempfile::tempdir().unwrap();
let flatpak_library_directory = temp_dir.path().join("app").join("lib");
create_pdfium_library_in(&flatpak_library_directory);
assert_eq!(
select_pdfium_library_directory_for(temp_dir.path(), false, &flatpak_library_directory),
None
);
}
fn create_pdfium_library_in(path: &Path) {
@@ -1082,4 +1002,42 @@ mod tests {
assert!(!is_tauri_asset_url(&url));
assert!(!is_local_http_url(&url));
}
#[test]
fn audio_capture_is_allowed_for_exact_approved_app_origin() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
let current = tauri::Url::parse("http://localhost:12345/voice-recorder").unwrap();
assert!(should_allow_audio_capture(Some(&approved), Some(&current), true, false));
}
#[test]
fn audio_capture_is_denied_for_wrong_port() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
let current = tauri::Url::parse("http://localhost:54321/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&current), true, false));
}
#[test]
fn audio_capture_is_denied_for_external_origin() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
let current = tauri::Url::parse("https://example.com/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&current), true, false));
}
#[test]
fn video_capture_is_denied() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&approved), false, true));
}
#[test]
fn combined_audio_and_video_capture_is_denied() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&approved), true, true));
}
}
+251 -16
View File
@@ -1,10 +1,80 @@
use std::fmt::Display;
use std::sync::Mutex;
use arboard::Clipboard;
use log::{debug, error};
use axum::Json;
use log::{debug, error, warn};
use once_cell::sync::Lazy;
use serde::Serialize;
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// The process-wide clipboard instance. On Linux, retaining this instance keeps the app's
/// ownership of clipboard contents alive until the next write or application shutdown.
static CLIPBOARD: Lazy<Mutex<Option<Clipboard>>> = Lazy::new(|| Mutex::new(None));
trait ClipboardBackend {
type Error: Display;
fn set_text(&mut self, text: String) -> Result<(), Self::Error>;
}
impl ClipboardBackend for Clipboard {
type Error = arboard::Error;
fn set_text(&mut self, text: String) -> Result<(), Self::Error> {
Clipboard::set_text(self, text)
}
}
#[derive(Debug, PartialEq, Eq)]
enum ClipboardOperationError<E> {
Initialization(E),
Write(E),
}
impl<E: Display> Display for ClipboardOperationError<E> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Initialization(error) => write!(formatter, "Failed to initialize the clipboard backend: {error}"),
Self::Write(error) => write!(formatter, "Failed to write to the clipboard: {error}"),
}
}
}
fn set_text_with_retry<B, F>(
clipboard: &mut Option<B>,
text: String,
mut create_clipboard: F,
) -> Result<(), ClipboardOperationError<B::Error>>
where
B: ClipboardBackend,
F: FnMut() -> Result<B, B::Error>,
{
if clipboard.is_none() {
*clipboard = Some(create_clipboard().map_err(ClipboardOperationError::Initialization)?);
}
let first_result = clipboard.as_mut().unwrap().set_text(text.clone());
if let Err(first_error) = first_result {
warn!(Source = "Clipboard"; "Failed to set text using the current clipboard backend; reinitializing it once: {first_error}.");
*clipboard = None;
let mut retry_clipboard = create_clipboard().map_err(ClipboardOperationError::Initialization)?;
if let Err(retry_error) = retry_clipboard.set_text(text) {
error!(Source = "Clipboard"; "Failed to set text after reinitializing the clipboard backend: {retry_error}.");
return Err(ClipboardOperationError::Write(retry_error));
}
*clipboard = Some(retry_clipboard);
}
Ok(())
}
fn release_clipboard<B>(clipboard: &mut Option<B>) -> bool {
clipboard.take().is_some()
}
/// Sets the clipboard text to the provided encrypted text.
pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<SetClipboardResponse> {
let encrypted_text = EncryptedText::new(encrypted_text);
@@ -21,20 +91,8 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
},
};
let clipboard_result = Clipboard::new();
let mut clipboard = match clipboard_result {
Ok(clipboard) => clipboard,
Err(e) => {
error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}.");
return Json(SetClipboardResponse {
success: false,
issue: e.to_string(),
})
},
};
let set_text_result = clipboard.set_text(decrypted_text);
match set_text_result {
let mut clipboard = CLIPBOARD.lock().unwrap();
match set_text_with_retry(&mut clipboard, decrypted_text, Clipboard::new) {
Ok(_) => {
debug!(Source = "Clipboard"; "Text was set to the clipboard successfully.");
Json(SetClipboardResponse {
@@ -44,7 +102,7 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
},
Err(e) => {
error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}.");
error!(Source = "Clipboard"; "Clipboard operation failed: {e}.");
Json(SetClipboardResponse {
success: false,
issue: e.to_string(),
@@ -53,9 +111,186 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
}
}
/// Releases the process-wide clipboard instance during application shutdown.
pub fn shutdown_clipboard() {
let mut clipboard = CLIPBOARD.lock().unwrap();
if release_clipboard(&mut clipboard) {
debug!(Source = "Clipboard"; "Clipboard instance was released.");
}
}
/// The response for setting the clipboard text.
#[derive(Serialize)]
pub struct SetClipboardResponse {
success: bool,
issue: String,
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::{ClipboardOperationError, release_clipboard, set_text_with_retry, ClipboardBackend};
struct MockClipboard {
id: usize,
fail_write: bool,
writes: Arc<Mutex<Vec<(usize, String)>>>,
drops: Arc<AtomicUsize>,
}
impl ClipboardBackend for MockClipboard {
type Error = String;
fn set_text(&mut self, text: String) -> Result<(), Self::Error> {
self.writes.lock().unwrap().push((self.id, text));
if self.fail_write {
Err(format!("backend {} failed", self.id))
} else {
Ok(())
}
}
}
impl Drop for MockClipboard {
fn drop(&mut self) {
self.drops.fetch_add(1, Ordering::SeqCst);
}
}
struct MockFactory {
outcomes: VecDeque<bool>,
created: usize,
writes: Arc<Mutex<Vec<(usize, String)>>>,
drops: Arc<AtomicUsize>,
}
impl MockFactory {
fn new(outcomes: impl IntoIterator<Item = bool>) -> Self {
Self {
outcomes: outcomes.into_iter().collect(),
created: 0,
writes: Arc::new(Mutex::new(Vec::new())),
drops: Arc::new(AtomicUsize::new(0)),
}
}
fn create(&mut self) -> Result<MockClipboard, String> {
let fail_write = self.outcomes.pop_front().expect("missing mock outcome");
let id = self.created;
self.created += 1;
Ok(MockClipboard {
id,
fail_write,
writes: Arc::clone(&self.writes),
drops: Arc::clone(&self.drops),
})
}
}
#[test]
fn initializes_lazily() {
let mut clipboard = None;
let mut factory = MockFactory::new([false]);
assert_eq!(factory.created, 0);
set_text_with_retry(&mut clipboard, "first".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 1);
assert!(clipboard.is_some());
}
#[test]
fn reports_initialization_failures_and_retries_on_the_next_request() {
let mut clipboard: Option<MockClipboard> = None;
let mut factory = MockFactory::new([false]);
let mut fail_initialization = true;
let error = set_text_with_retry(&mut clipboard, "first".to_string(), || {
if fail_initialization {
fail_initialization = false;
Err("initialization failed".to_string())
} else {
factory.create()
}
}).unwrap_err();
assert_eq!(error, ClipboardOperationError::Initialization("initialization failed".to_string()));
assert!(clipboard.is_none());
set_text_with_retry(&mut clipboard, "second".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 1);
assert!(clipboard.is_some());
}
#[test]
fn reuses_the_same_instance_for_multiple_writes() {
let mut clipboard = None;
let mut factory = MockFactory::new([false]);
set_text_with_retry(&mut clipboard, "first".to_string(), || factory.create()).unwrap();
set_text_with_retry(&mut clipboard, "second".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 1);
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "first".to_string()), (0, "second".to_string())]);
}
#[test]
fn retries_once_with_a_new_instance_after_a_write_failure() {
let mut clipboard = None;
let mut factory = MockFactory::new([true, false]);
set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 2);
assert_eq!(clipboard.as_ref().unwrap().id, 1);
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "text".to_string()), (1, "text".to_string())]);
}
#[test]
fn reports_reinitialization_failures_and_discards_the_failed_instance() {
let mut clipboard = None;
let mut factory = MockFactory::new([true]);
let mut initialization_attempts = 0;
let error = set_text_with_retry(&mut clipboard, "text".to_string(), || {
initialization_attempts += 1;
if initialization_attempts == 1 {
factory.create()
} else {
Err("reinitialization failed".to_string())
}
}).unwrap_err();
assert_eq!(error, ClipboardOperationError::Initialization("reinitialization failed".to_string()));
assert_eq!(initialization_attempts, 2);
assert!(clipboard.is_none());
}
#[test]
fn returns_the_retry_error_and_discards_the_failed_instance() {
let mut clipboard = None;
let mut factory = MockFactory::new([true, true]);
let error = set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap_err();
assert_eq!(error, ClipboardOperationError::Write("backend 1 failed".to_string()));
assert_eq!(factory.created, 2);
assert!(clipboard.is_none());
}
#[test]
fn releases_the_instance_on_shutdown() {
let mut clipboard = None;
let mut factory = MockFactory::new([false]);
let drops = Arc::clone(&factory.drops);
set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap();
assert!(release_clipboard(&mut clipboard));
assert!(clipboard.is_none());
assert_eq!(drops.load(Ordering::SeqCst), 1);
}
}
+2
View File
@@ -31,6 +31,8 @@ pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS: &str = "AI_STUDIO_EX
#[cfg(any(target_os = "linux", test))]
const FLATPAK_ENTERPRISE_POLICY_DIRECTORY: &str = "/app/etc/MindWorkAI";
pub(crate) const FLATPAK_LIBRARY_DIRECTORY: &str = "/app/lib";
const ENTERPRISE_ENV_CONFIG_ID_PREFIX: &str = "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID";
const ENTERPRISE_ENV_CONFIG_SERVER_URL_PREFIX: &str = "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL";
const ENTERPRISE_ENV_CONFIGS: &str = "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS";
+337 -5
View File
@@ -2,10 +2,24 @@
use axum::extract::Query;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
use crate::api_token::APIToken;
use crate::app_window::MAIN_WINDOW;
#[cfg(any(windows, target_os = "macos"))]
use std::process::Command;
#[cfg(target_os = "linux")]
use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
/// Microsoft documents CREATE_NO_WINDOW as a process creation flag with value 0x08000000.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Clone, Deserialize)]
pub struct PreviousDirectory {
path: String,
@@ -32,10 +46,15 @@ pub struct SelectFileOptions {
#[derive(Clone, Deserialize)]
pub struct SaveFileOptions {
title: String,
name_file: Option<PreviousFile>,
previous_file: Option<PreviousFile>,
filter: Option<FileTypeFilter>,
}
#[derive(Clone, Deserialize)]
pub struct OpenPathOptions {
path: String,
}
#[derive(Serialize)]
pub struct DirectorySelectionResponse {
user_cancelled: bool,
@@ -60,6 +79,12 @@ pub struct FileSaveResponse {
save_file_path: String,
}
#[derive(Serialize)]
pub struct OpenPathResponse {
success: bool,
issue: String,
}
#[derive(Clone, Deserialize)]
pub struct PreviousFile {
file_path: String,
@@ -250,10 +275,15 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
// 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);
// Set the initial directory and file name if provided:
if let Some(previous) = &payload.previous_file {
let (directory, file_name) = split_save_file_path(&previous.file_path);
if let Some(directory) = directory {
file_dialog = file_dialog.set_directory(directory);
}
if let Some(file_name) = file_name {
file_dialog = file_dialog.set_file_name(file_name);
}
}
// Displays the file dialogue box and select the file:
@@ -286,6 +316,79 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
}
}
pub async fn open_path_in_file_manager(
_token: APIToken,
payload: Json<OpenPathOptions>,
) -> Json<OpenPathResponse> {
let requested_path = PathBuf::from(payload.path.trim());
if requested_path.as_os_str().is_empty() {
return Json(OpenPathResponse {
success: false,
issue: String::from("The path is empty."),
});
}
let Some(target) = resolve_file_manager_target(&requested_path) else {
let issue = format!(
"The path does not exist and its parent folder could not be found: {}",
requested_path.to_string_lossy(),
);
error!(Source = "Tauri"; "{issue}");
return Json(OpenPathResponse {
success: false,
issue,
});
};
#[cfg(target_os = "linux")]
{
return match open_path_in_linux_file_manager(&target).await {
Ok(()) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
Err(issue) => {
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
}
};
}
#[cfg(any(windows, target_os = "macos"))]
{
let mut command = create_file_manager_command(&target);
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
match command.spawn() {
Ok(_) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
Err(error) => {
let issue = format!("Failed to open the file manager: {error}");
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
}
}
}
}
/// Applies an optional file type filter to a FileDialogBuilder.
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
match filter {
@@ -296,4 +399,233 @@ fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &O
None => file_dialog,
}
}
fn split_save_file_path(file_path: &str) -> (Option<PathBuf>, Option<String>) {
let path = Path::new(file_path);
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf);
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty());
(directory, file_name)
}
#[derive(Debug, PartialEq, Eq)]
struct FileManagerTarget {
path: PathBuf,
reveal_file: bool,
}
#[cfg(any(target_os = "linux", test))]
#[derive(Debug, PartialEq, Eq)]
enum LinuxPortalOperation {
RevealFile,
OpenDirectory,
}
fn resolve_file_manager_target(requested_path: &Path) -> Option<FileManagerTarget> {
if requested_path.is_file() {
return Some(FileManagerTarget {
path: requested_path.to_path_buf(),
reveal_file: true,
});
}
if requested_path.is_dir() {
return Some(FileManagerTarget {
path: requested_path.to_path_buf(),
reveal_file: false,
});
}
requested_path.parent()
.filter(|parent| parent.is_dir())
.map(|parent| FileManagerTarget {
path: parent.to_path_buf(),
reveal_file: false,
})
}
#[cfg(any(target_os = "linux", test))]
fn linux_portal_operation(target: &FileManagerTarget) -> LinuxPortalOperation {
if target.reveal_file {
LinuxPortalOperation::RevealFile
} else {
LinuxPortalOperation::OpenDirectory
}
}
#[cfg(any(target_os = "linux", test))]
fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path {
if target.reveal_file {
target.path.parent().unwrap_or(&target.path)
} else {
&target.path
}
}
#[cfg(target_os = "linux")]
enum LinuxPortalError {
Unavailable(String),
RequestFailed(String),
}
#[cfg(target_os = "linux")]
async fn open_path_with_linux_portal(target: &FileManagerTarget) -> Result<(), LinuxPortalError> {
let file = std::fs::File::open(&target.path)
.map_err(|error| LinuxPortalError::Unavailable(format!("Failed to open the path for the desktop portal: {error}")))?;
let request = match linux_portal_operation(target) {
LinuxPortalOperation::RevealFile => OpenDirectoryRequest::default().send(&file).await,
LinuxPortalOperation::OpenDirectory => OpenFileRequest::default().send_file(&file).await,
}
.map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?;
request.response()
.map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}")))
}
#[cfg(target_os = "linux")]
async fn open_path_with_xdg_open(target: &FileManagerTarget) -> Result<(), String> {
let fallback_path = xdg_open_fallback_path(target);
let status = tokio::process::Command::new("xdg-open")
.arg(fallback_path)
.status()
.await
.map_err(|error| format!("xdg-open failed to start for '{}': {error}", fallback_path.to_string_lossy()))?;
if status.success() {
Ok(())
} else {
Err(format!("xdg-open failed for '{}' with exit status {status}", fallback_path.to_string_lossy()))
}
}
#[cfg(target_os = "linux")]
async fn open_path_in_linux_file_manager(target: &FileManagerTarget) -> Result<(), String> {
match open_path_with_linux_portal(target).await {
Ok(()) => Ok(()),
Err(LinuxPortalError::RequestFailed(error)) => Err(error),
Err(LinuxPortalError::Unavailable(portal_error)) => {
match open_path_with_xdg_open(target).await {
Ok(()) => Ok(()),
Err(fallback_error) => Err(format!("{portal_error} Fallback failed: {fallback_error}")),
}
}
}
}
#[cfg(target_os = "windows")]
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
let mut command = Command::new("explorer.exe");
if target.reveal_file {
command.arg(format!("/select,{}", target.path.to_string_lossy()));
} else {
command.arg(&target.path);
}
command
}
#[cfg(target_os = "macos")]
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
let mut command = Command::new("open");
if target.reveal_file {
command.arg("-R");
}
command.arg(&target.path);
command
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn save_file_options_accept_the_previous_file_contract() {
let options: SaveFileOptions = serde_json::from_str(
r#"{"title":"Export visual briefing","previous_file":{"file_path":"Quarterly briefing.html"}}"#,
)
.unwrap();
assert_eq!(options.title, "Export visual briefing");
assert_eq!(
options.previous_file.unwrap().file_path,
"Quarterly briefing.html",
);
}
#[test]
fn save_file_name_without_directory_is_preserved() {
let (directory, file_name) = split_save_file_path("Quarterly briefing.html");
assert_eq!(directory, None);
assert_eq!(file_name.as_deref(), Some("Quarterly briefing.html"));
}
#[test]
fn save_file_path_is_split_into_directory_and_name() {
let temp_dir = tempfile::tempdir().unwrap();
let initial_path = temp_dir.path().join("Quarterly briefing.html");
let (directory, file_name) = split_save_file_path(initial_path.to_str().unwrap());
assert_eq!(directory.as_deref(), Some(temp_dir.path()));
assert_eq!(file_name.as_deref(), Some("Quarterly briefing.html"));
}
#[test]
fn existing_file_is_revealed_and_falls_back_to_its_parent() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("application.log");
fs::write(&file_path, "log").unwrap();
let target = resolve_file_manager_target(&file_path).unwrap();
assert_eq!(target.path, file_path);
assert!(target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::RevealFile);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn existing_directory_is_opened_directly() {
let temp_dir = tempfile::tempdir().unwrap();
let target = resolve_file_manager_target(temp_dir.path()).unwrap();
assert_eq!(target.path, temp_dir.path());
assert!(!target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn missing_file_uses_its_existing_parent_directory() {
let temp_dir = tempfile::tempdir().unwrap();
let missing_file = temp_dir.path().join("missing.log");
let target = resolve_file_manager_target(&missing_file).unwrap();
assert_eq!(target.path, temp_dir.path());
assert!(!target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn invalid_path_without_existing_parent_is_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let invalid_path = temp_dir.path().join("missing-directory").join("missing.log");
assert!(resolve_file_manager_target(&invalid_path).is_none());
}
}
+155 -56
View File
@@ -12,7 +12,7 @@ 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 pptx_to_md::{DiagnosticSeverity, ImageHandlingMode, MarkdownOptions, ParserConfig, PresentationContainer, PresentationFormat, PresentationMetadata, ReadingOrder};
use serde::{Deserialize, Deserializer, Serialize};
use serde::de::{Error as SerdeError, Visitor};
use std::path::Path;
@@ -190,10 +190,14 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
},
};
let ext = file_path.split('.').next_back().unwrap_or("");
let ext = Path::new(file_path)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
debug!("Extracting data from file: '{file_path}', format: '{fmt:?}', extension: '{ext}'");
let stream = match ext {
let stream = match ext.as_str() {
DOCX | ODT => {
let from = if ext == DOCX { "docx" } else { "odt" };
convert_with_pandoc(file_path, from, TO_MARKDOWN).await?
@@ -203,7 +207,8 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
stream_text_file(file_path, true, Some("csv".to_string())).await?
},
"pptx" => stream_pptx(file_path, extract_images).await?,
"pptx" => stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?,
"odp" => stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?,
"xlsx" | "ods" | "xls" | "xlsm" | "xlsb" | "xla" | "xlam" => {
stream_spreadsheet_as_csv(file_path).await?
@@ -244,8 +249,11 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
Kind::Presentation => match fmt {
FileFormat::OfficeOpenXmlPresentation => {
stream_pptx(file_path, extract_images).await?
stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?
},
FileFormat::OpendocumentPresentation => {
stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?
}
_ => stream_text_file(file_path, false, None).await?,
},
@@ -448,7 +456,7 @@ async fn chunk_image(file_path: &str) -> Result<ChunkStream> {
Ok(Box::pin(stream))
}
async fn stream_pptx(file_path: &str, extract_images: bool) -> Result<ChunkStream> {
async fn stream_presentation(file_path: &str, extract_images: bool, format: PresentationFormat) -> Result<ChunkStream> {
let path = Path::new(file_path).to_owned();
let parser_config = ParserConfig::builder()
@@ -456,76 +464,167 @@ async fn stream_pptx(file_path: &str, extract_images: bool) -> Result<ChunkStrea
.compress_images(true)
.quality(75)
.image_handling_mode(ImageHandlingMode::Manually)
.include_presentation_metadata(true)
.build();
let markdown_options = MarkdownOptions {
reading_order: ReadingOrder::Spatial,
include_slide_number_as_comment: true,
include_speaker_notes: true,
include_comments: true,
render_unsupported_comments: true,
};
let mut streamer = tokio::task::spawn_blocking(move || {
PptxContainer::open(&path, parser_config).map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
PresentationContainer::open_as(&path, parser_config, format).map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
}).await??;
let (tx, rx) = mpsc::channel(32);
let worker_error_tx = tx.clone();
// Slide iteration performs synchronous ZIP/XML work and image compression,
// so the complete producer must stay outside Tokio's asynchronous workers.
let worker = tokio::task::spawn_blocking(move || {
let mut metadata_md = presentation_metadata_to_markdown(streamer.metadata());
tokio::spawn(async move {
for slide_result in streamer.iter_slides() {
match slide_result {
Ok(slide) => {
if let Some(md_content) = slide.convert_to_md() {
let slide = match slide_result {
Ok(slide) => slide,
Err(e) => {
let _ = tx.blocking_send(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>));
return;
},
};
for diagnostic in &slide.diagnostics {
let source = diagnostic.source.as_deref().unwrap_or("presentation");
match diagnostic.severity {
DiagnosticSeverity::Warning => warn!(
"Presentation slide {} warning in '{}': {}",
slide.slide_number,
source,
diagnostic.message
),
DiagnosticSeverity::Error => error!(
"Presentation slide {} error in '{}': {}",
slide.slide_number,
source,
diagnostic.message
),
}
}
let mut content = match slide.to_markdown(&markdown_options) {
Ok(content) => content,
Err(e) => {
let _ = tx.blocking_send(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>));
return;
},
};
if let Some(metadata) = metadata_md.take() {
content = format!("{metadata}\n\n{content}");
}
let chunk = Chunk::new(
content,
Metadata::Presentation {
slide_number: slide.slide_number,
image: None,
}
);
if tx.blocking_send(Ok(chunk)).is_err() {
return;
}
if let Some(images) = slide.load_images_manually() {
for image in images.iter() {
let base64_data = &image.base64_content;
let total_length = base64_data.len();
let mut offset = 0;
let mut segment_index = 0;
while offset < total_length {
let end = min(offset + IMAGE_SEGMENT_SIZE_IN_CHARS, total_length);
let segment_content = &base64_data[offset..end];
let is_end = end == total_length;
let base64_image = Base64Image::new(
image.img_ref.id.clone(),
segment_content.to_string(),
segment_index,
is_end
);
let chunk = Chunk::new(
md_content,
String::new(),
Metadata::Presentation {
slide_number: slide.slide_number,
image: None,
image: Some(base64_image),
}
);
if tx.send(Ok(chunk)).await.is_err() {
break;
if tx.blocking_send(Ok(chunk)).is_err() {
return;
}
offset = end;
segment_index += 1;
}
if let Some(images) = slide.load_images_manually() {
for image in images.iter() {
let base64_data = &image.base64_content;
let total_length = base64_data.len();
let mut offset = 0;
let mut segment_index = 0;
while offset < total_length {
let end = min(offset + IMAGE_SEGMENT_SIZE_IN_CHARS, total_length);
let segment_content = &base64_data[offset..end];
let is_end = end == total_length;
let base64_image = Base64Image::new(
image.img_ref.id.clone(),
segment_content.to_string(),
segment_index,
is_end
);
let chunk = Chunk::new(
String::new(),
Metadata::Presentation {
slide_number: slide.slide_number,
image: Some(base64_image),
}
);
if tx.send(Ok(chunk)).await.is_err() {
break;
}
offset = end;
segment_index += 1;
}
}
}
},
Err(e) => {
let _ = tx.send(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)).await;
break;
}
}
}
});
tokio::spawn(async move {
if let Err(e) = worker.await {
let _ = worker_error_tx.send(Err(format!("Presentation parser task failed: {e}").into())).await;
}
});
Ok(Box::pin(ReceiverStream::new(rx)))
}
fn presentation_metadata_to_markdown(metadata: &PresentationMetadata) -> Option<String> {
let mut fields = Vec::new();
push_presentation_metadata_field(&mut fields, "Title", metadata.title.as_deref());
push_presentation_metadata_field(&mut fields, "Author", metadata.author.as_deref());
push_presentation_metadata_field(&mut fields, "Last Modified By", metadata.last_modified_by.as_deref());
push_presentation_metadata_field(&mut fields, "Subject", metadata.subject.as_deref());
push_presentation_metadata_field(&mut fields, "Description", metadata.description.as_deref());
if !metadata.keywords.is_empty() {
fields.push(format!(
"Keywords: {}",
sanitize_presentation_metadata_value(&metadata.keywords.join("; "))
));
}
push_presentation_metadata_field(&mut fields, "Created", metadata.created_at.as_deref());
push_presentation_metadata_field(&mut fields, "Modified", metadata.modified_at.as_deref());
if fields.is_empty() {
None
} else {
Some(format!(
"<!-- Presentation Metadata\n{}\n-->",
fields.join("\n")
))
}
}
fn push_presentation_metadata_field(fields: &mut Vec<String>, label: &str, value: Option<&str>) {
if let Some(value) = value {
fields.push(format!(
"{label}: {}",
sanitize_presentation_metadata_value(value)
));
}
}
fn sanitize_presentation_metadata_value(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace("--", "&#45;&#45;")
}
File diff suppressed because it is too large. Load diff
+385
View File
@@ -0,0 +1,385 @@
//! Local image preparation: decode a file, apply the size policy, and return it as a Data URL.
//!
//! This module is deliberately free of any feature-specific behavior so that every part of
//! AI Studio that needs an embeddable image can use it. The size policy is a single maximum edge
//! length; callers that want the original bytes pass `optimize = false`.
use std::io::Cursor;
use std::path::Path;
use axum::Json;
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat, ImageReader};
use serde::{Deserialize, Serialize};
/// The longest edge an optimized image may have. Larger images are scaled down proportionally.
const MAX_EDGE_PIXELS: u32 = 2_560;
/// The quality used when re-encoding JPEG images. Pinned so that repeated runs are byte-identical.
const JPEG_QUALITY: u8 = 85;
/// The request to prepare one local image file.
#[derive(Debug, Deserialize)]
pub struct PrepareImageRequest {
/// The absolute path of the image file to read.
path: String,
/// Whether the size policy and re-encoding are applied. When false, the original bytes are used.
optimize: bool,
}
/// The prepared image together with the dimensions the caller can lay out against.
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct PrepareImageResponse {
/// The complete `data:` URL, ready to embed.
data_url: String,
/// The MIME type matching the source format.
mime_type: String,
/// The width of the prepared image in pixels.
width: u32,
/// The height of the prepared image in pixels.
height: u32,
/// Whether the size policy actually scaled the image down.
was_resized: bool,
}
/// Decodes one supported image, applies the size policy, and returns a Data URL.
///
/// Decoding runs on a blocking worker because it is CPU-bound and would otherwise stall the
/// async runtime for large images.
pub async fn prepare_image(
Json(request): Json<PrepareImageRequest>,
) -> Result<Json<PrepareImageResponse>, (StatusCode, String)> {
tokio::task::spawn_blocking(move || prepare_image_sync(&request))
.await
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The image worker failed: {error}"),
)
})?
.map(Json)
}
/// Performs the blocking part of [`prepare_image`].
///
/// Only absolute paths to existing files are accepted, and the decoded format has to match the
/// file extension. Rejecting a mismatch keeps a file that merely claims to be an image from being
/// embedded under a MIME type derived from its name.
fn prepare_image_sync(
request: &PrepareImageRequest,
) -> Result<PrepareImageResponse, (StatusCode, String)> {
let path = Path::new(&request.path);
if !path.is_absolute() || !path.is_file() {
return Err((
StatusCode::BAD_REQUEST,
"The image path is not an accessible absolute file path.".to_string(),
));
}
let format = supported_format(path)?;
let reader = ImageReader::open(path)
.and_then(|reader| reader.with_guessed_format())
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be opened: {error}"),
)
})?;
if reader.format() != Some(format) {
return Err((
StatusCode::BAD_REQUEST,
"The image content does not match its file extension.".to_string(),
));
}
let decoded = reader.decode().map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be decoded: {error}"),
)
})?;
let original_width = decoded.width();
let original_height = decoded.height();
let should_resize = request.optimize && original_width.max(original_height) > MAX_EDGE_PIXELS;
let prepared = if should_resize {
resize_to_max_edge(decoded)
} else {
decoded
};
let width = prepared.width();
let height = prepared.height();
let bytes = if request.optimize {
encode(&prepared, format)?
} else {
std::fs::read(path).map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be read: {error}"),
)
})?
};
let mime_type = match format {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::WebP => "image/webp",
_ => unreachable!(),
}
.to_string();
Ok(PrepareImageResponse {
data_url: format!(
"data:{mime_type};base64,{}",
general_purpose::STANDARD.encode(bytes)
),
mime_type,
width,
height,
was_resized: should_resize,
})
}
/// Maps a file extension to the one image format AI Studio embeds.
///
/// The result is only the expected format; [`prepare_image_sync`] still verifies it against the
/// actual file content.
fn supported_format(path: &Path) -> Result<ImageFormat, (StatusCode, String)> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("jpg" | "jpeg") => Ok(ImageFormat::Jpeg),
Some("png") => Ok(ImageFormat::Png),
Some("webp") => Ok(ImageFormat::WebP),
_ => Err((
StatusCode::BAD_REQUEST,
"Images must be PNG, JPEG, or WebP files.".to_string(),
)),
}
}
/// Scales an image down so that its longest edge equals [`MAX_EDGE_PIXELS`].
///
/// The aspect ratio is preserved, and both edges stay at least one pixel wide.
fn resize_to_max_edge(image: DynamicImage) -> DynamicImage {
let width = image.width();
let height = image.height();
let scale = MAX_EDGE_PIXELS as f64 / width.max(height) as f64;
let target_width = (width as f64 * scale).round().max(1.0) as u32;
let target_height = (height as f64 * scale).round().max(1.0) as u32;
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
}
/// Encodes a prepared image back into its source format.
///
/// JPEG uses the pinned [`JPEG_QUALITY`] so that the same input always produces the same bytes,
/// which keeps artifact hashes stable across runs.
fn encode(image: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, (StatusCode, String)> {
let mut bytes = Vec::new();
match format {
ImageFormat::Jpeg => JpegEncoder::new_with_quality(&mut bytes, JPEG_QUALITY)
.encode_image(image)
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The JPEG image could not be encoded: {error}"),
)
})?,
ImageFormat::Png | ImageFormat::WebP => image
.write_to(&mut Cursor::new(&mut bytes), format)
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The image could not be encoded: {error}"),
)
})?,
_ => unreachable!(),
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn temporary_image_path(extension: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{unique}.{extension}"))
}
#[test]
fn rejects_unsupported_visual_asset_extension() {
let issue = supported_format(Path::new("/tmp/asset.gif")).unwrap_err();
assert_eq!(issue.0, StatusCode::BAD_REQUEST);
}
#[test]
fn keeps_supported_formats_stable() {
assert_eq!(
supported_format(Path::new("/tmp/asset.jpeg")).unwrap(),
ImageFormat::Jpeg
);
assert_eq!(
supported_format(Path::new("/tmp/asset.png")).unwrap(),
ImageFormat::Png
);
assert_eq!(
supported_format(Path::new("/tmp/asset.webp")).unwrap(),
ImageFormat::WebP
);
}
#[test]
fn serializes_response_in_snake_case_for_the_rust_service_contract() {
let response = PrepareImageResponse {
data_url: "data:image/jpeg;base64,/9j/".to_string(),
mime_type: "image/jpeg".to_string(),
width: 17,
height: 11,
was_resized: false,
};
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["data_url"], "data:image/jpeg;base64,/9j/");
assert_eq!(json["mime_type"], "image/jpeg");
assert_eq!(json["width"], 17);
assert_eq!(json["height"], 11);
assert_eq!(json["was_resized"], false);
assert!(json.get("dataUrl").is_none());
assert!(json.get("mimeType").is_none());
assert!(json.get("wasResized").is_none());
}
#[test]
fn disabled_optimization_preserves_original_bytes() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(4, 3)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let original = std::fs::read(&path).unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: false,
})
.unwrap();
let encoded = response.data_url.split_once(',').unwrap().1;
assert_eq!(general_purpose::STANDARD.decode(encoded).unwrap(), original);
assert!(!response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_resizes_only_images_over_the_maximum_edge() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(MAX_EDGE_PIXELS + 1, 1)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
assert_eq!(response.width, MAX_EDGE_PIXELS);
assert_eq!(response.height, 1);
assert!(response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_keeps_images_at_the_maximum_edge_unchanged() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(MAX_EDGE_PIXELS, 2)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
assert_eq!(response.width, MAX_EDGE_PIXELS);
assert_eq!(response.height, 2);
assert!(!response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimized_jpeg_uses_the_pinned_quality_encoder() {
let path = temporary_image_path("jpg");
let image = DynamicImage::new_rgb8(17, 11);
image.save_with_format(&path, ImageFormat::Jpeg).unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
let actual = general_purpose::STANDARD
.decode(response.data_url.split_once(',').unwrap().1)
.unwrap();
let mut expected = Vec::new();
JpegEncoder::new_with_quality(&mut expected, JPEG_QUALITY)
.encode_image(&image)
.unwrap();
assert_eq!(actual, expected);
assert_eq!(response.mime_type, "image/jpeg");
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_keeps_png_and_webp_formats_stable() {
for (extension, format, expected_mime) in [
("png", ImageFormat::Png, "image/png"),
("webp", ImageFormat::WebP, "image/webp"),
] {
let path = temporary_image_path(extension);
DynamicImage::new_rgba8(9, 7)
.save_with_format(&path, format)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
let bytes = general_purpose::STANDARD
.decode(response.data_url.split_once(',').unwrap().1)
.unwrap();
assert_eq!(image::guess_format(&bytes).unwrap(), format);
assert_eq!(response.mime_type, expected_mime);
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn rejects_file_contents_that_do_not_match_the_extension() {
let path = temporary_image_path("png");
std::fs::write(&path, b"not an image").unwrap();
let issue = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap_err();
assert_eq!(issue.0, StatusCode::BAD_REQUEST);
std::fs::remove_file(path).unwrap();
}
}
+3
View File
@@ -11,6 +11,8 @@ pub mod runtime_api;
pub mod runtime_certificate;
pub mod file_data;
pub mod metadata;
pub mod media;
pub mod image;
pub mod pdfium;
pub mod pandoc;
pub mod qdrant_edge_database;
@@ -20,3 +22,4 @@ pub mod stale_process_cleanup;
mod sidecar_types;
pub mod tokenizer;
mod file_actions;
pub mod global_shortcuts;
+243 -12
View File
@@ -2,7 +2,8 @@ use std::collections::BTreeMap;
use std::env::{current_dir, temp_dir};
use std::error::Error;
use std::fmt::Debug;
use std::path::{absolute, PathBuf};
use std::fs::{create_dir_all, OpenOptions};
use std::path::{absolute, Path, PathBuf};
use std::sync::OnceLock;
use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle};
use flexi_logger::writers::FileLogWriter;
@@ -11,7 +12,9 @@ use log::kv::{Key, Value, VisitSource};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::environment::is_dev;
use crate::environment::{is_dev, is_flatpak};
const FLATPAK_PERSISTENT_DATA_DIRECTORY: &str = "/var/data";
static LOGGER: OnceLock<RuntimeLoggerHandle> = OnceLock::new();
@@ -20,7 +23,7 @@ static LOG_STARTUP_PATH: OnceLock<String> = OnceLock::new();
static LOG_APP_PATH: OnceLock<String> = OnceLock::new();
/// Initialize the logging system.
pub fn init_logging() {
pub fn init_logging(bundle_identifier: &str) {
//
// Configure the LOGGER:
@@ -43,6 +46,7 @@ pub fn init_logging() {
log_config.push_str("tower_http=info, ");
log_config.push_str("rustls=info, ");
log_config.push_str("tokio_rustls=info, ");
log_config.push_str("symphonia_format_mkv=info, ");
log_config.push_str("reqwest=info");
// Configure the initial filename. On Unix systems, the file should start
@@ -53,14 +57,15 @@ pub fn init_logging() {
false => "AI Studio Events",
};
let (startup_log_directory, fallback_warning) = get_startup_log_path(bundle_identifier);
let log_path = FileSpec::default()
.directory(get_startup_log_path())
.directory(startup_log_directory)
.basename(log_basename)
.suppress_timestamp()
.suffix("log");
// Store the startup log path:
let _ = LOG_STARTUP_PATH.set(convert_log_path_to_string(&log_path));
store_startup_log_path(&LOG_STARTUP_PATH, &log_path);
let runtime_logger = Logger::try_with_str(log_config).expect("Cannot create logging")
.log_to_file(log_path)
@@ -77,6 +82,14 @@ pub fn init_logging() {
};
LOGGER.set(runtime_logger).expect("Cannot set LOGGER");
if let Some(fallback_warning) = fallback_warning {
log::warn!("{fallback_warning}");
}
}
fn store_startup_log_path(storage: &OnceLock<String>, log_path: &FileSpec) {
let _ = storage.set(convert_log_path_to_string(log_path));
}
fn convert_log_path_to_string(log_path: &FileSpec) -> String {
@@ -105,25 +118,123 @@ fn convert_log_path_to_string(log_path: &FileSpec) -> String {
}
}
fn get_startup_log_path(bundle_identifier: &str) -> (PathBuf, Option<String>) {
if is_flatpak() {
return select_flatpak_startup_log_path(
bundle_identifier,
dirs::data_local_dir(),
PathBuf::from(FLATPAK_PERSISTENT_DATA_DIRECTORY),
temp_dir(),
ensure_log_directory_is_writable,
).unwrap_or_else(|error| panic!("Cannot prepare a Flatpak startup log directory: {error}"));
}
(get_non_flatpak_startup_log_path(
home_directory(),
current_dir().ok(),
temp_dir(),
), None)
}
// Note: Rust plans to remove the deprecation flag for std::env::home_dir() in Rust 1.86.0.
#[allow(deprecated)]
fn get_startup_log_path() -> String {
match std::env::home_dir() {
fn home_directory() -> Option<PathBuf> {
std::env::home_dir()
}
fn get_non_flatpak_startup_log_path(
home_directory: Option<PathBuf>,
working_directory: Option<PathBuf>,
temporary_directory: PathBuf,
) -> PathBuf {
match home_directory {
// Case: We could determine the home directory:
Some(home_dir) => home_dir.to_str().unwrap().to_string(),
Some(home_directory) => home_directory,
// Case: We could not determine the home directory. Let's try to use the working directory:
None => match current_dir() {
None => match working_directory {
// Case: We could determine the working directory:
Ok(working_directory) => working_directory.to_str().unwrap().to_string(),
Some(working_directory) => working_directory,
// Case: We could not determine the working directory. Let's use the temporary directory:
Err(_) => temp_dir().to_str().unwrap().to_string(),
None => temporary_directory,
},
}
}
fn select_flatpak_startup_log_path<F>(
bundle_identifier: &str,
data_local_directory: Option<PathBuf>,
persistent_data_directory: PathBuf,
temporary_directory: PathBuf,
mut ensure_writable: F,
) -> Result<(PathBuf, Option<String>), String>
where
F: FnMut(&Path) -> Result<(), String>,
{
let standard_directory = data_local_directory.map(|directory| directory.join(bundle_identifier).join("data"));
let persistent_fallback = persistent_data_directory.join(bundle_identifier).join("data");
let temporary_fallback = temporary_directory.join(bundle_identifier).join("data");
let mut failures = Vec::new();
if let Some(standard_directory) = standard_directory {
match ensure_writable(&standard_directory) {
Ok(()) => return Ok((standard_directory, None)),
Err(error) => failures.push(format!("standard path failed: {error}")),
}
} else {
failures.push(String::from("standard path failed: dirs::data_local_dir() returned no path"));
}
match ensure_writable(&persistent_fallback) {
Ok(()) => {
let warning = format!(
"The standard Flatpak startup log directory was unavailable; using persistent fallback '{}'. {}",
persistent_fallback.display(),
failures.join("; "),
);
return Ok((persistent_fallback, Some(warning)));
},
Err(error) => failures.push(format!("persistent fallback failed: {error}")),
}
match ensure_writable(&temporary_fallback) {
Ok(()) => {
let warning = format!(
"The standard and persistent Flatpak startup log directories were unavailable; using temporary fallback '{}'. {}",
temporary_fallback.display(),
failures.join("; "),
);
Ok((temporary_fallback, Some(warning)))
},
Err(error) => {
failures.push(format!("temporary fallback failed: {error}"));
Err(failures.join("; "))
},
}
}
fn ensure_log_directory_is_writable(directory: &Path) -> Result<(), String> {
create_dir_all(directory).map_err(|error| format!("could not create '{}': {error}", directory.display()))?;
let log_file_path = directory.join(if cfg!(unix) {
".AI Studio Events.log"
} else {
"AI Studio Events.log"
});
OpenOptions::new()
.create(true)
.append(true)
.open(&log_file_path)
.map(|_| ())
.map_err(|error| format!("could not write '{}': {error}", log_file_path.display()))
}
/// Switch the logging system to a file-based output inside the given directory.
pub fn switch_to_file_logging(logger_path: PathBuf) -> Result<(), Box<dyn Error>>{
let log_path = FileSpec::default()
@@ -223,7 +334,7 @@ fn file_logger_format(
write_kv_pairs(w, record)?;
// Write the log message:
write!(w, "{}", &record.args())
write!(w, "{}", record.args())
}
pub async fn get_log_paths(_token: APIToken) -> Json<LogPathsResponse> {
@@ -315,4 +426,124 @@ pub struct LogEvent {
pub struct LogEventResponse {
success: bool,
issue: String,
}
#[cfg(test)]
mod tests {
use super::*;
const BUNDLE_IDENTIFIER: &str = "org.mindworkai.AIStudio";
#[test]
fn flatpak_standard_path_matches_tauri_local_data_path() {
let base_directory = PathBuf::from("/var/data");
let expected = base_directory.join(BUNDLE_IDENTIFIER).join("data");
let (selected, warning) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
Some(base_directory),
PathBuf::from("/persistent"),
PathBuf::from("/temporary"),
|_| Ok(()),
).unwrap();
assert_eq!(selected, expected);
assert!(warning.is_none());
}
#[test]
fn flatpak_uses_persistent_fallback_when_standard_path_is_unwritable() {
let standard = PathBuf::from("/standard").join(BUNDLE_IDENTIFIER).join("data");
let persistent = PathBuf::from("/var/data").join(BUNDLE_IDENTIFIER).join("data");
let (selected, warning) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
Some(PathBuf::from("/standard")),
PathBuf::from("/var/data"),
PathBuf::from("/temporary"),
|candidate| {
if candidate == standard {
Err(String::from("read-only"))
} else {
Ok(())
}
},
).unwrap();
assert_eq!(selected, persistent);
assert!(warning.unwrap().contains("persistent fallback"));
}
#[test]
fn flatpak_uses_temporary_fallback_when_persistent_path_is_unwritable() {
let temporary = PathBuf::from("/tmp").join(BUNDLE_IDENTIFIER).join("data");
let (selected, warning) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
None,
PathBuf::from("/var/data"),
PathBuf::from("/tmp"),
|candidate| {
if candidate == temporary {
Ok(())
} else {
Err(String::from("read-only"))
}
},
).unwrap();
assert_eq!(selected, temporary);
assert!(warning.unwrap().contains("temporary fallback"));
}
#[test]
fn non_flatpak_path_selection_keeps_existing_fallback_order() {
let home = PathBuf::from("/home/user");
let working = PathBuf::from("/working");
let temporary = PathBuf::from("/tmp");
assert_eq!(
get_non_flatpak_startup_log_path(Some(home.clone()), Some(working.clone()), temporary.clone()),
home,
);
assert_eq!(
get_non_flatpak_startup_log_path(None, Some(working.clone()), temporary.clone()),
working,
);
assert_eq!(
get_non_flatpak_startup_log_path(None, None, temporary.clone()),
temporary,
);
}
#[test]
fn startup_log_path_storage_uses_selected_fallback_path() {
let temporary = PathBuf::from("/tmp").join(BUNDLE_IDENTIFIER).join("data");
let (selected, _) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
None,
PathBuf::from("/var/data"),
PathBuf::from("/tmp"),
|candidate| {
if candidate == temporary {
Ok(())
} else {
Err(String::from("unavailable"))
}
},
).unwrap();
let log_path = FileSpec::default()
.directory(selected)
.basename(".AI Studio Events")
.suppress_timestamp()
.suffix("log");
let storage = OnceLock::new();
store_startup_log_path(&storage, &log_path);
assert_eq!(
storage.get().unwrap(),
"/tmp/org.mindworkai.AIStudio/data/.AI Studio Events.log",
);
}
}
+19 -4
View File
@@ -12,11 +12,26 @@ 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() {
// Keep `main` synchronous. Tauri owns the application's Tokio runtime, and Tauri itself as
// well as synchronous plugins may internally call `block_on` while they are initialized.
// In v26.7.3, `#[tokio::main]` caused the Linux single-instance plugin's synchronous D-Bus
// setup to enter `block_on` through zbus/tokio. Cargo feature unification made that path use
// Tokio, so startup panicked because a runtime was being started from inside another runtime.
//
// Run asynchronous background work with `tauri::async_runtime::spawn`. If startup must await
// asynchronous work, call `tauri::async_runtime::block_on` from this synchronous function
// before Tauri enters its event loop. If a real `#[tokio::main]` ever becomes unavoidable,
// first call `tauri::async_runtime::set(tokio::runtime::Handle::current())` before using any
// Tauri async function. Then audit every synchronously initialized Tauri plugin and transitive
// dependency for internal `block_on` calls. In particular, the Linux single-instance/D-Bus
// path must be made async, replaced, or moved to a non-conflicting backend. Such a runtime
// change requires explicit Linux startup tests; compiling successfully is not sufficient.
fn main() {
let metadata = MetaData::init_from_string(include_str!("../../metadata.txt"));
let tauri_context = tauri::generate_context!();
let bundle_identifier = tauri_context.config().identifier.clone();
init_logging();
init_logging(&bundle_identifier);
info!("Starting MindWork AI Studio:");
let working_directory = std::env::current_dir().unwrap();
@@ -46,5 +61,5 @@ async fn main() {
generate_runtime_certificate();
start_runtime_api();
start_tauri();
start_tauri(tauri_context);
}
+1971
View File
File diff suppressed because it is too large. Load diff
+26 -20
View File
@@ -1,42 +1,45 @@
use std::error::Error;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use once_cell::sync::{Lazy, OnceCell};
use pdfium_render::prelude::Pdfium;
use log::{error, warn};
use log::{error, info, 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));
static PDFIUM: OnceCell<Pdfium> = OnceCell::new();
pub trait PdfiumInit {
fn ai_studio_init() -> Result<Pdfium, Box<dyn Error + Send + Sync>>;
fn ai_studio_init() -> Result<&'static 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 Error + Send + Sync>> {
let mut pdfium = PDFIUM.lock().unwrap();
if let Some(pdfium) = pdfium.as_ref() {
return Ok(pdfium.clone());
}
let loaded_pdfium = load_pdfium().map_err(|error| {
fn ai_studio_init() -> Result<&'static Pdfium, Box<dyn Error + Send + Sync>> {
PDFIUM.get_or_try_init(|| 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)),
let pdfium_library_path = Pdfium::pdfium_platform_library_name_at_path(path);
return match Pdfium::bind_to_library(&pdfium_library_path) {
Ok(binding) => {
info!("Loaded PDFium from '{path}'.", path = pdfium_library_path.to_string_lossy());
Ok(Pdfium::new(binding))
},
Err(library_error) => {
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Ok(binding) => {
info!(
"Loaded PDFium from the system library after failing to load '{path}'.",
path = pdfium_library_path.to_string_lossy(),
);
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}'."
@@ -52,7 +55,10 @@ fn load_pdfium() -> Result<Pdfium, String> {
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)),
Ok(binding) => {
info!("Loaded PDFium from the system library.");
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}'."
@@ -62,4 +68,4 @@ fn load_pdfium() -> Result<Pdfium, String> {
Err(error_message)
}
}
}
}
+5 -6
View File
@@ -541,12 +541,11 @@ fn remove_obsolete_qdrant_sidecar_files<R: tauri::Runtime>(app_handle: &tauri::A
cfg_if::cfg_if! {
if #[cfg(any(target_os = "windows", target_os = "macos"))]{
if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent() {
if exe_dir.to_string_lossy().contains("MindWork AI Studio") {
paths.push(exe_dir.join("target").join("databases").join("qdrant"));
paths.push(exe_dir.join("qdrant.exe"));
paths.push(exe_dir.join("qdrant"));
}
if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent()
&& exe_dir.to_string_lossy().contains("MindWork AI Studio") {
paths.push(exe_dir.join("target").join("databases").join("qdrant"));
paths.push(exe_dir.join("qdrant.exe"));
paths.push(exe_dir.join("qdrant"));
}
}
}
+6 -1
View File
@@ -1,6 +1,6 @@
use log::info;
use once_cell::sync::Lazy;
use axum::routing::{get, post};
use axum::routing::{delete, get, post};
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
@@ -49,6 +49,7 @@ pub fn start_runtime_api() {
.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("/open/path", post(crate::file_actions::open_path_in_file_manager))
.route("/secrets/get", post(crate::secret::get_secret))
.route("/secrets/store", post(crate::secret::store_secret))
.route("/secrets/delete", post(crate::secret::delete_secret))
@@ -62,6 +63,10 @@ pub fn start_runtime_api() {
.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("/media/jobs", post(crate::media::create_job))
.route("/media/jobs/{id}/events", get(crate::media::get_job_events))
.route("/media/jobs/{id}", delete(crate::media::cancel_job))
.route("/image/prepare", post(crate::image::prepare_image))
.route("/log/paths", get(crate::log::get_log_paths))
.route("/log/event", post(crate::log::log_event))
.route("/tokenizer/count", post(crate::tokenizer::token_count))
+96
View File
@@ -5,6 +5,46 @@ use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// A structured issue reported by the native credential store.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub enum SecretStoreIssueCode {
None,
SecretNotFound,
NoDefaultCollection,
CollectionLocked,
PromptDismissed,
ServiceUnavailable,
Unknown,
}
fn issue_code(error: &KeyringError) -> SecretStoreIssueCode {
if matches!(error, KeyringError::NoEntry) {
return SecretStoreIssueCode::SecretNotFound;
}
#[cfg(target_os = "linux")]
if let KeyringError::PlatformFailure(error) | KeyringError::NoStorageAccess(error) = error {
if let Some(error) = error.downcast_ref::<dbus_secret_service::Error>() {
return secret_service_issue_code(error);
}
}
SecretStoreIssueCode::Unknown
}
#[cfg(target_os = "linux")]
fn secret_service_issue_code(error: &dbus_secret_service::Error) -> SecretStoreIssueCode {
use dbus_secret_service::Error;
match error {
Error::NoResult => SecretStoreIssueCode::NoDefaultCollection,
Error::Locked => SecretStoreIssueCode::CollectionLocked,
Error::Prompt => SecretStoreIssueCode::PromptDismissed,
Error::Unavailable => SecretStoreIssueCode::ServiceUnavailable,
_ => SecretStoreIssueCode::Unknown,
}
}
/// Initializes the native credential store used by keyring-core.
pub fn init_secret_store() {
cfg_if::cfg_if! {
@@ -48,6 +88,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
return Json(StoreSecretResponse {
success: false,
issue: format!("Failed to decrypt the text: {e}"),
issue_code: SecretStoreIssueCode::Unknown,
})
},
};
@@ -60,6 +101,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
return Json(StoreSecretResponse {
success: false,
issue: e.to_string(),
issue_code: issue_code(&e),
});
},
};
@@ -70,6 +112,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
Json(StoreSecretResponse {
success: true,
issue: String::from(""),
issue_code: SecretStoreIssueCode::None,
})
},
@@ -78,6 +121,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
Json(StoreSecretResponse {
success: false,
issue: e.to_string(),
issue_code: issue_code(&e),
})
},
}
@@ -96,6 +140,7 @@ pub struct StoreSecret {
pub struct StoreSecretResponse {
success: bool,
issue: String,
issue_code: SecretStoreIssueCode,
}
/// Retrieves a secret from the secret store using the operating system's keyring.
@@ -113,6 +158,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to create secret entry for '{service}' and user '{user_name}': {e}"),
issue_code: issue_code(&e),
});
},
};
@@ -130,6 +176,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to encrypt the secret: {e}"),
issue_code: SecretStoreIssueCode::Unknown,
});
},
};
@@ -138,6 +185,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: true,
secret: encrypted_secret,
issue: String::from(""),
issue_code: SecretStoreIssueCode::None,
})
},
@@ -150,6 +198,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"),
issue_code: issue_code(&e),
})
},
}
@@ -169,6 +218,7 @@ pub struct RequestedSecret {
success: bool,
secret: EncryptedText,
issue: String,
issue_code: SecretStoreIssueCode,
}
/// Deletes a secret from the secret store using the operating system's keyring.
@@ -183,6 +233,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: false,
was_entry_found: false,
issue: e.to_string(),
issue_code: issue_code(&e),
});
},
};
@@ -195,6 +246,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: true,
was_entry_found: true,
issue: String::from(""),
issue_code: SecretStoreIssueCode::None,
})
},
@@ -204,6 +256,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: true,
was_entry_found: false,
issue: String::from(""),
issue_code: SecretStoreIssueCode::SecretNotFound,
})
}
@@ -213,6 +266,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: false,
was_entry_found: false,
issue: e.to_string(),
issue_code: issue_code(&e),
})
},
}
@@ -224,4 +278,46 @@ pub struct DeleteSecretResponse {
success: bool,
was_entry_found: bool,
issue: String,
issue_code: SecretStoreIssueCode,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_entry_is_reported_as_secret_not_found() {
assert_eq!(issue_code(&KeyringError::NoEntry), SecretStoreIssueCode::SecretNotFound);
}
#[test]
fn unrelated_keyring_error_uses_unknown_fallback() {
let error = KeyringError::Invalid("service".to_string(), "invalid".to_string());
assert_eq!(issue_code(&error), SecretStoreIssueCode::Unknown);
}
#[test]
fn issue_code_is_included_in_json() {
let response = StoreSecretResponse {
success: false,
issue: "technical details".to_string(),
issue_code: SecretStoreIssueCode::NoDefaultCollection,
};
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["issue_code"], "NoDefaultCollection");
assert_eq!(json["issue"], "technical details");
}
#[cfg(target_os = "linux")]
#[test]
fn secret_service_errors_are_mapped_to_issue_codes() {
use dbus_secret_service::Error;
assert_eq!(secret_service_issue_code(&Error::NoResult), SecretStoreIssueCode::NoDefaultCollection);
assert_eq!(secret_service_issue_code(&Error::Locked), SecretStoreIssueCode::CollectionLocked);
assert_eq!(secret_service_issue_code(&Error::Prompt), SecretStoreIssueCode::PromptDismissed);
assert_eq!(secret_service_issue_code(&Error::Unavailable), SecretStoreIssueCode::ServiceUnavailable);
assert_eq!(secret_service_issue_code(&Error::Parse), SecretStoreIssueCode::Unknown);
}
}