mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-10 00:53:48 +00:00
Merge branch 'main' into chunk-data
This commit is contained in:
commit
9932b5870b
484 files changed
+45549
-2467
No files matched your search
+201
-243
@@ -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(¤t), 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(¤t), 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(¤t), 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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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("--", "--")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+26
-20
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user