mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 10:53:37 +00:00
Resolved 29 conflicting files. The notable decisions: Confidence: main's tool-calling gate (RequiredProviderConfidence) and this branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule on the same axis, so they are now one field. Both tool results and data sources raise it through RequireProviderConfidence(). The gate checks the level strictly and no longer exempts providers trusted by configuration: TrustedProviderIds is documented as applying to data-source security checks only, and organizations set confidence through DataConfidence .CustomConfidenceScheme instead. The security axis (DataSecurity, ERI, IsTrustedForDataSourceSecurityChecks) is unchanged. Provider creation: main's CreateProvider signature won (hfEndpointKind, capabilityOverrides, no model parameter); tokenizerPath was added to it and is set for every provider, including the new Hetzner, IONOS and LiteLLM. Provider and EmbeddingProvider combine the record parameters, Lua parsing and Lua serialization of both sides. File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the runtime already reads. Both sides had added a conflicting HTML filter; the reading family keeps the name, and the export path uses a narrow HTML_DOCUMENT, following the existing LATEX/TEX split. Runtime: main's file_data.rs is the base, including the prompt-injection sanitizer and the extraction routes. Token counting and chunk segmentation moved into take_released, so they act on the text the filter has released rather than on text it is still holding. A failed count is logged and left out instead of ending the extraction, because the app counts such a segment itself. Data sources: the participating-provider checks of this branch are kept, and main's GetAllowedDataSources overload now builds on them. DirectChatService resolves the launched chat's data source options before the check, so filter and chat see the same options. .NET and Rust both build clean; I18N regenerated to 4060 keys.
1082 lines
40 KiB
Rust
1082 lines
40 KiB
Rust
use std::convert::Infallible;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
use async_stream::stream;
|
|
use axum::body::Body;
|
|
use axum::http::header::CONTENT_TYPE;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use bytes::Bytes;
|
|
use log::{debug, error, info, trace, warn};
|
|
use once_cell::sync::Lazy;
|
|
use pdfium_render::prelude::Pdfium;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
|
|
use tauri::path::PathResolver;
|
|
use tauri::WebviewWindow;
|
|
use tauri_plugin_updater::{UpdaterExt, Update};
|
|
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::{
|
|
installation_kind, is_prod, is_dev, is_flatpak, InstallationKind, 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));
|
|
|
|
/// The update response coming from the Tauri updater.
|
|
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// The event broadcast sender for Tauri events.
|
|
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// 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));
|
|
|
|
/// Starts the Tauri app.
|
|
pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
|
|
info!("Starting Tauri app...");
|
|
|
|
// Create the event broadcast channel:
|
|
let (event_sender, root_event_receiver) = broadcast::channel(100);
|
|
|
|
// Save a copy of the event broadcast sender for later use:
|
|
*EVENT_BROADCAST.lock().unwrap() = Some(event_sender.clone());
|
|
|
|
// When the last receiver is dropped, we lose the ability to send events.
|
|
// Therefore, we spawn a task that keeps the root receiver alive:
|
|
tauri::async_runtime::spawn(async move {
|
|
let mut root_receiver = root_event_receiver;
|
|
loop {
|
|
match root_receiver.recv().await {
|
|
Ok(event) => {
|
|
debug!(Source = "Tauri"; "Tauri event received: location=root receiver , event={event:?}");
|
|
},
|
|
|
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
|
warn!(Source = "Tauri"; "Root event receiver lagged, skipped {skipped} messages.");
|
|
},
|
|
|
|
Err(broadcast::error::RecvError::Closed) => {
|
|
warn!(Source = "Tauri"; "Root event receiver channel closed.");
|
|
return;
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
let app = tauri::Builder::default()
|
|
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
|
info!(Source = "Tauri"; "Prevented second app instance from starting. cwd='{cwd}', args={args:?}");
|
|
|
|
let Some(window) = app.get_webview_window("main") else {
|
|
warn!(Source = "Tauri"; "Second app instance was blocked, but the main window was not available for activation.");
|
|
return;
|
|
};
|
|
|
|
if let Err(error) = window.show() {
|
|
warn!(Source = "Tauri"; "Failed to show main window after second app start: {error}");
|
|
}
|
|
|
|
if let Err(error) = window.unminimize() {
|
|
warn!(Source = "Tauri"; "Failed to unminimize main window after second app start: {error}");
|
|
}
|
|
|
|
if let Err(error) = window.set_focus() {
|
|
warn!(Source = "Tauri"; "Failed to focus main window after second app start: {error}");
|
|
}
|
|
}))
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_shell::init())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(
|
|
tauri::plugin::Builder::<tauri::Wry, ()>::new("external-link-handler")
|
|
.on_navigation(|webview, url| {
|
|
if !should_open_in_system_browser(webview, url) {
|
|
return true;
|
|
}
|
|
|
|
match webview.app_handle().opener().open_url(url.as_str(), None::<&str>) {
|
|
Ok(_) => {
|
|
info!(Source = "Tauri"; "Opening external URL in system browser: {url}");
|
|
},
|
|
Err(error) => {
|
|
error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}");
|
|
},
|
|
}
|
|
false
|
|
})
|
|
.build(),
|
|
)
|
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.setup(move |app| {
|
|
|
|
// Get the main window:
|
|
let window = app.get_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):
|
|
window.on_window_event(move |event| {
|
|
debug!(Source = "Tauri"; "Tauri event received: location=window event handler, event={event:?}");
|
|
let event_to_send = Event::from_window_event(event);
|
|
let sender = event_sender.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match sender.send(event_to_send) {
|
|
Ok(_) => {},
|
|
Err(error) => error!(Source = "Tauri"; "Failed to channel window event: {error}"),
|
|
}
|
|
});
|
|
});
|
|
|
|
// Save the main window for later access:
|
|
*MAIN_WINDOW.lock().unwrap() = Some(window);
|
|
|
|
info!(Source = "Bootloader Tauri"; "Setup is running.");
|
|
let data_path = app.path().app_local_data_dir().unwrap();
|
|
let data_path = data_path.join("data");
|
|
|
|
// Get and store the data and config directories:
|
|
DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the data directory.")).unwrap();
|
|
CONFIG_DIRECTORY.set(app.path().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
|
|
|
|
if is_dev() {
|
|
#[cfg(debug_assertions)]
|
|
create_startup_env_file();
|
|
} else {
|
|
cleanup_dotnet_server();
|
|
start_dotnet_server(app.handle().clone());
|
|
}
|
|
|
|
start_qdrant_edge_database(app.handle().clone());
|
|
|
|
set_default_tokenizer_path(app.handle().clone());
|
|
|
|
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}");
|
|
switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap();
|
|
set_pdfium_path(app.path());
|
|
|
|
Ok(())
|
|
})
|
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
|
.build(tauri_context)
|
|
.expect("Error while running Tauri application");
|
|
|
|
// The app event handler:
|
|
app.run(|_app_handle, event| {
|
|
if !matches!(event, RunEvent::MainEventsCleared) {
|
|
debug!(Source = "Tauri"; "Tauri event received: location=app event handler , event={event:?}");
|
|
}
|
|
|
|
match event {
|
|
RunEvent::WindowEvent { event, label, .. } => {
|
|
match event {
|
|
WindowEvent::CloseRequested { .. } => {
|
|
warn!(Source = "Tauri"; "Window '{label}': close was requested.");
|
|
}
|
|
|
|
WindowEvent::Destroyed => {
|
|
warn!(Source = "Tauri"; "Window '{label}': was destroyed.");
|
|
}
|
|
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
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...");
|
|
stop_dotnet_server();
|
|
}
|
|
}
|
|
|
|
RunEvent::Ready => {
|
|
info!(Source = "Tauri"; "Run event: Tauri app is ready.");
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
warn!(Source = "Tauri"; "Tauri app was stopped.");
|
|
}
|
|
|
|
fn is_local_host(host: Option<&str>) -> bool {
|
|
matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]"))
|
|
}
|
|
|
|
fn is_tauri_asset_host(host: Option<&str>) -> bool {
|
|
matches!(host, Some("tauri.localhost"))
|
|
}
|
|
|
|
fn is_tauri_asset_url(url: &tauri::Url) -> bool {
|
|
matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str())
|
|
}
|
|
|
|
fn is_local_http_url(url: &tauri::Url) -> bool {
|
|
matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str())
|
|
}
|
|
|
|
fn same_origin(left: &tauri::Url, right: &tauri::Url) -> bool {
|
|
left.scheme() == right.scheme()
|
|
&& left.host_str() == right.host_str()
|
|
&& left.port_or_known_default() == right.port_or_known_default()
|
|
}
|
|
|
|
#[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,
|
|
"http" | "https" => {},
|
|
_ => return false,
|
|
}
|
|
|
|
if is_tauri_asset_url(url) {
|
|
return false;
|
|
}
|
|
|
|
if let Some(approved_app_url) = APPROVED_APP_URL.lock().unwrap().as_ref() {
|
|
if same_origin(approved_app_url, url) {
|
|
return false;
|
|
}
|
|
|
|
if is_local_http_url(url) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if let Ok(current_url) = webview.url() && same_origin(¤t_url, url) {
|
|
return false;
|
|
}
|
|
|
|
!is_local_host(url.host_str())
|
|
}
|
|
|
|
/// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client.
|
|
/// If no events are available for a certain time, we send a ping event to keep the connection alive.
|
|
/// When the client disconnects, the stream is closed. But we try to not lose events in between.
|
|
/// The client is expected to reconnect automatically when the connection is closed and continue
|
|
/// listening for events.
|
|
pub async fn get_event_stream(_token: APIToken) -> Response {
|
|
// Get the lock to the event broadcast sender:
|
|
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
|
|
|
|
// Get and subscribe to the event receiver:
|
|
let mut event_receiver = event_broadcast_lock.as_ref()
|
|
.expect("Event sender not initialized.")
|
|
.subscribe();
|
|
|
|
// Drop the lock to allow other access to the sender:
|
|
drop(event_broadcast_lock);
|
|
|
|
let stream = stream! {
|
|
loop {
|
|
// Wait at most 3 seconds for an event:
|
|
match time::timeout(Duration::from_secs(3), event_receiver.recv()).await {
|
|
|
|
// Case: we received an event
|
|
Ok(Ok(event)) => {
|
|
// Serialize the event to JSON. Important is that the entire event
|
|
// is serialized as a single line so that the client can parse it
|
|
// correctly:
|
|
let event_json = serde_json::to_string(&event).unwrap();
|
|
yield Ok::<Bytes, Infallible>(Bytes::from(event_json));
|
|
|
|
// The client expects a newline after each event because we are using
|
|
// a method to read the stream line-by-line:
|
|
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
|
|
},
|
|
|
|
// Case: we lagged behind and missed some events
|
|
Ok(Err(broadcast::error::RecvError::Lagged(skipped))) => {
|
|
warn!(Source = "Tauri"; "Event receiver lagged, skipped {skipped} messages.");
|
|
},
|
|
|
|
// Case: the event channel was closed
|
|
Ok(Err(broadcast::error::RecvError::Closed)) => {
|
|
warn!(Source = "Tauri"; "Event receiver channel closed.");
|
|
return;
|
|
},
|
|
|
|
// Case: timeout. We will send a ping event to keep the connection alive.
|
|
Err(_) => {
|
|
let ping_event = Event::new(TauriEventType::Ping, Vec::new());
|
|
|
|
// Again, we have to serialize the event as a single line:
|
|
let event_json = serde_json::to_string(&ping_event).unwrap();
|
|
yield Ok::<Bytes, Infallible>(Bytes::from(event_json));
|
|
|
|
// The client expects a newline after each event because we are using
|
|
// a method to read the stream line-by-line:
|
|
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
([(CONTENT_TYPE, "application/jsonl")], Body::from_stream(stream)).into_response()
|
|
}
|
|
|
|
/// Data structure representing a Tauri event for our event API.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Event {
|
|
pub event_type: TauriEventType,
|
|
pub payload: Vec<String>,
|
|
}
|
|
|
|
/// Implementation of the Event struct.
|
|
impl Event {
|
|
|
|
/// Creates a new Event instance.
|
|
pub fn new(event_type: TauriEventType, payload: Vec<String>) -> Self {
|
|
Event {
|
|
payload,
|
|
event_type,
|
|
}
|
|
}
|
|
|
|
/// Creates an Event instance from a Tauri WindowEvent.
|
|
pub fn from_window_event(window_event: &WindowEvent) -> Self {
|
|
match window_event {
|
|
WindowEvent::DragDrop(drop_event) => {
|
|
match drop_event {
|
|
DragDropEvent::Enter { paths, .. } => Event::new(
|
|
TauriEventType::FileDropHovered,
|
|
paths.iter().map(|p| p.display().to_string()).collect(),
|
|
),
|
|
|
|
DragDropEvent::Drop { paths, .. } => Event::new(
|
|
TauriEventType::FileDropDropped,
|
|
paths.iter().map(|p| p.display().to_string()).collect(),
|
|
),
|
|
|
|
DragDropEvent::Leave => Event::new(TauriEventType::FileDropCanceled, Vec::new()),
|
|
|
|
_ => Event::new(TauriEventType::Unknown, Vec::new()),
|
|
}
|
|
},
|
|
|
|
WindowEvent::Focused(state) => if *state {
|
|
Event::new(TauriEventType::WindowFocused,
|
|
Vec::new(),
|
|
)
|
|
} else {
|
|
Event::new(TauriEventType::WindowNotFocused,
|
|
Vec::new(),
|
|
)
|
|
},
|
|
|
|
_ => Event::new(TauriEventType::Unknown,
|
|
Vec::new(),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The types of Tauri events we can send through our event API.
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub enum TauriEventType {
|
|
None,
|
|
Ping,
|
|
Unknown,
|
|
|
|
WindowFocused,
|
|
WindowNotFocused,
|
|
|
|
FileDropHovered,
|
|
FileDropDropped,
|
|
FileDropCanceled,
|
|
|
|
GlobalShortcutPressed,
|
|
GlobalShortcutChanged,
|
|
}
|
|
|
|
/// Changes the location of the main window to the given URL.
|
|
pub async fn change_location_to(url: &str) {
|
|
// Try to get the main window. If it is not available yet, wait for it:
|
|
let mut main_window_ready = false;
|
|
let mut main_window_status_reported = false;
|
|
let main_window_spawn_clone = &MAIN_WINDOW;
|
|
while !main_window_ready
|
|
{
|
|
main_window_ready = {
|
|
let main_window = main_window_spawn_clone.lock().unwrap();
|
|
main_window.is_some()
|
|
};
|
|
|
|
if !main_window_ready {
|
|
if !main_window_status_reported {
|
|
info!("Waiting for main window to be ready, because .NET was faster than Tauri.");
|
|
main_window_status_reported = true;
|
|
}
|
|
|
|
time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
if let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) {
|
|
*APPROVED_APP_URL.lock().unwrap() = Some(parsed_url);
|
|
}
|
|
|
|
let js_location_change = format!("window.location = '{url}';");
|
|
let main_window = main_window_spawn_clone.lock().unwrap();
|
|
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
|
|
match location_change_result {
|
|
Ok(_) => info!("The app location was changed to {url}."),
|
|
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
|
}
|
|
}
|
|
|
|
/// Checks for updates.
|
|
pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
|
|
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
|
|
warn!(Source = "Updater"; "Skipping update check because {reason}.");
|
|
return Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: false,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
});
|
|
}
|
|
|
|
let app_handle = {
|
|
let main_window = MAIN_WINDOW.lock().unwrap();
|
|
match main_window.as_ref() {
|
|
Some(window) => window.app_handle().clone(),
|
|
None => {
|
|
error!(Source = "Updater"; "Cannot check updates: main window not available.");
|
|
return Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: true,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
let response = match app_handle.updater() {
|
|
Ok(updater) => updater.check().await,
|
|
Err(e) => {
|
|
warn!(Source = "Updater"; "Failed to get updater instance: {e}");
|
|
return Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: true,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
});
|
|
}
|
|
};
|
|
|
|
match response {
|
|
Ok(Some(update)) => {
|
|
let body_len = update.body.as_ref().map_or(0, |body| body.len());
|
|
let date = update.date;
|
|
let new_version = update.version.clone();
|
|
info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={new_version}");
|
|
let changelog = update.body.clone().unwrap_or_default();
|
|
*CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update);
|
|
Json(CheckUpdateResponse {
|
|
update_is_available: true,
|
|
error: false,
|
|
new_version,
|
|
changelog,
|
|
})
|
|
}
|
|
Ok(None) => {
|
|
info!(Source = "Tauri"; "Updater: app is already up to date");
|
|
Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: false,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
|
|
Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: true,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The response to the check for update request.
|
|
#[derive(Serialize)]
|
|
pub struct CheckUpdateResponse {
|
|
update_is_available: bool,
|
|
error: bool,
|
|
new_version: String,
|
|
changelog: String,
|
|
}
|
|
|
|
/// Installs the update.
|
|
pub async fn install_update(_token: APIToken) {
|
|
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
|
|
warn!(Source = "Updater"; "Skipping update installation because {reason}.");
|
|
return;
|
|
}
|
|
|
|
let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone();
|
|
let app_handle = MAIN_WINDOW
|
|
.lock()
|
|
.unwrap()
|
|
.as_ref()
|
|
.map(|window| window.app_handle().clone());
|
|
|
|
match cloned_response_option {
|
|
Some(update_response) => {
|
|
info!(Source = "Tauri"; "Updater: update is pending!");
|
|
let result = update_response.download_and_install(
|
|
|chunk_length, _content_length| {
|
|
trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes");
|
|
},
|
|
|| {
|
|
info!(Source = "Tauri"; "Updater: update has been downloaded!");
|
|
warn!(Source = "Tauri"; "Try to stop the .NET server now...");
|
|
|
|
if is_prod() {
|
|
stop_dotnet_server();
|
|
stop_qdrant_edge_database();
|
|
} else {
|
|
warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server.");
|
|
}
|
|
},
|
|
).await;
|
|
|
|
match result {
|
|
Ok(_) => {
|
|
info!(Source = "Tauri"; "Updater: app has been updated");
|
|
warn!(Source = "Tauri"; "Try to restart the app now...");
|
|
|
|
if is_prod() {
|
|
if let Some(handle) = app_handle {
|
|
handle.restart();
|
|
} else {
|
|
warn!(Source = "Tauri"; "Cannot restart after update: main window not available.");
|
|
}
|
|
} else {
|
|
warn!(Source = "Tauri"; "Development environment detected; do not restart the app.");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
|
|
}
|
|
}
|
|
},
|
|
|
|
None => {
|
|
error!(Source = "Updater"; "No update available to install. Did you check for updates first?");
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Returns why this installation cannot update itself, or `None` when it can.
|
|
fn self_update_blocked_reason(flatpak: bool, installation_kind: InstallationKind) -> Option<&'static str> {
|
|
if flatpak {
|
|
return Some("Flatpak installations are updated externally");
|
|
}
|
|
|
|
match installation_kind {
|
|
InstallationKind::User => None,
|
|
InstallationKind::Managed => Some("this installation is centrally managed"),
|
|
InstallationKind::UnsupportedLocation => Some("this installation is in a location the updater cannot replace"),
|
|
InstallationKind::Development => Some("the app is running in development mode"),
|
|
}
|
|
}
|
|
|
|
/// Response for application exit requests.
|
|
#[derive(Serialize)]
|
|
pub struct AppExitResponse {
|
|
success: bool,
|
|
error_message: String,
|
|
}
|
|
|
|
/// Requests a controlled shutdown of the entire desktop application.
|
|
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
|
|
let app_handle = {
|
|
let main_window_lock = MAIN_WINDOW.lock().unwrap();
|
|
match main_window_lock.as_ref() {
|
|
Some(window) => window.app_handle().clone(),
|
|
None => {
|
|
error!(Source = "Tauri"; "Cannot exit app: main window not available.");
|
|
return Json(AppExitResponse {
|
|
success: false,
|
|
error_message: "Main window not available".to_string(),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
info!(Source = "Tauri"; "Controlled app exit was requested by the UI.");
|
|
tauri::async_runtime::spawn(async move {
|
|
time::sleep(Duration::from_millis(50)).await;
|
|
app_handle.exit(0);
|
|
});
|
|
|
|
Json(AppExitResponse {
|
|
success: true,
|
|
error_message: String::new(),
|
|
})
|
|
}
|
|
|
|
/// 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 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.
|
|
#[derive(Clone, Deserialize)]
|
|
pub struct ValidateShortcutRequest {
|
|
/// The shortcut string to validate (e.g., "CmdOrControl+1").
|
|
shortcut: String,
|
|
}
|
|
|
|
/// Response for shortcut validation.
|
|
#[derive(Serialize)]
|
|
pub struct ShortcutValidationResponse {
|
|
is_valid: bool,
|
|
error_message: String,
|
|
has_conflict: bool,
|
|
conflict_description: String,
|
|
}
|
|
|
|
/// Validates a shortcut string without registering it.
|
|
/// Checks if the shortcut syntax is valid and if it
|
|
/// conflicts with existing shortcuts.
|
|
pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
|
|
let shortcut = payload.shortcut.clone();
|
|
|
|
// Empty shortcuts are always valid (means "disabled"):
|
|
if shortcut.is_empty() {
|
|
return Json(ShortcutValidationResponse {
|
|
is_valid: true,
|
|
error_message: String::new(),
|
|
has_conflict: false,
|
|
conflict_description: String::new(),
|
|
});
|
|
}
|
|
|
|
// Check if the shortcut is already registered:
|
|
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,
|
|
error_message: String::new(),
|
|
has_conflict: true,
|
|
conflict_description: format!("Already used by: {}", name),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
let is_valid = validate_shortcut_syntax(&shortcut);
|
|
|
|
if is_valid {
|
|
Json(ShortcutValidationResponse {
|
|
is_valid: true,
|
|
error_message: String::new(),
|
|
has_conflict: false,
|
|
conflict_description: String::new(),
|
|
})
|
|
} else {
|
|
Json(ShortcutValidationResponse {
|
|
is_valid: false,
|
|
error_message: format!("Invalid shortcut syntax: {}", shortcut),
|
|
has_conflict: false,
|
|
conflict_description: String::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
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> {
|
|
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.
|
|
fn validate_shortcut_syntax(shortcut: &str) -> bool {
|
|
let parts: Vec<&str> = shortcut.split('+').collect();
|
|
if parts.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let mut has_key = false;
|
|
for part in parts {
|
|
let part_lower = part.to_lowercase();
|
|
match part_lower.as_str() {
|
|
// Modifiers
|
|
"cmdorcontrol" | "commandorcontrol" | "ctrl" | "control" | "cmd" | "command" |
|
|
"shift" | "alt" | "meta" | "super" | "option" => continue,
|
|
|
|
// Keys - letters
|
|
"a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" |
|
|
"n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" => has_key = true,
|
|
|
|
// Keys - numbers
|
|
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => has_key = true,
|
|
|
|
// Keys - function keys
|
|
_ if part_lower.starts_with('f') && part_lower[1..].parse::<u32>().is_ok() => has_key = true,
|
|
|
|
// Keys - special
|
|
"space" | "enter" | "tab" | "escape" | "backspace" | "delete" | "insert" |
|
|
"home" | "end" | "pageup" | "pagedown" |
|
|
"up" | "down" | "left" | "right" |
|
|
"arrowup" | "arrowdown" | "arrowleft" | "arrowright" |
|
|
"minus" | "equal" | "bracketleft" | "bracketright" | "backslash" |
|
|
"semicolon" | "quote" | "backquote" | "comma" | "period" | "slash" => has_key = true,
|
|
|
|
// Keys - numpad
|
|
_ if part_lower.starts_with("num") => has_key = true,
|
|
|
|
// Unknown
|
|
_ => return false,
|
|
}
|
|
}
|
|
|
|
has_key
|
|
}
|
|
|
|
fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
|
|
let resource_dir = match path_resolver.resource_dir() {
|
|
Ok(path) => path,
|
|
Err(error) => {
|
|
error!(Source = "Bootloader Tauri"; "Failed to resolve resource dir: {error}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
match select_pdfium_library_directory(&resource_dir, is_flatpak()) {
|
|
Some(path) => {
|
|
*PDFIUM_LIB_PATH.lock().unwrap() = Some(path.to_string_lossy().to_string());
|
|
}
|
|
None => {
|
|
error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path.");
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
if pdfium_library_path.exists() {
|
|
return Some(path);
|
|
}
|
|
|
|
if path.exists() {
|
|
warn!(
|
|
Source = "Bootloader Tauri";
|
|
"PDFium library directory exists, but the library file was not found at '{path}'.",
|
|
path = pdfium_library_path.to_string_lossy(),
|
|
);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_in_development() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::Development).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_for_flatpak() {
|
|
assert!(self_update_blocked_reason(true, InstallationKind::User).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_for_managed_installations() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::Managed).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_for_unsupported_installation_locations() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::UnsupportedLocation).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn every_blocked_installation_kind_has_its_own_reason() {
|
|
let reasons = [
|
|
self_update_blocked_reason(false, InstallationKind::Managed),
|
|
self_update_blocked_reason(false, InstallationKind::UnsupportedLocation),
|
|
self_update_blocked_reason(false, InstallationKind::Development),
|
|
];
|
|
|
|
for (index, reason) in reasons.iter().enumerate() {
|
|
assert!(reason.is_some(), "expected a reason at index {index}");
|
|
assert_eq!(
|
|
reasons.iter().filter(|other| *other == reason).count(),
|
|
1,
|
|
"expected the reason at index {index} to be unique"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_enabled_for_normal_production_installations() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::User).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_prefers_resources_libraries() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let resources_libraries = temp_dir.path().join("resources").join("libraries");
|
|
let libraries = temp_dir.path().join("libraries");
|
|
create_pdfium_library_in(&resources_libraries);
|
|
create_pdfium_library_in(&libraries);
|
|
|
|
assert_eq!(
|
|
select_pdfium_library_directory(temp_dir.path(), false),
|
|
Some(resources_libraries)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_falls_back_when_first_directory_has_no_library() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let resources_libraries = temp_dir.path().join("resources").join("libraries");
|
|
let libraries = temp_dir.path().join("libraries");
|
|
fs::create_dir_all(&resources_libraries).unwrap();
|
|
create_pdfium_library_in(&libraries);
|
|
|
|
assert_eq!(
|
|
select_pdfium_library_directory(temp_dir.path(), false),
|
|
Some(libraries)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_requires_library_file() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
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(), 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) {
|
|
fs::create_dir_all(path).unwrap();
|
|
fs::File::create(Pdfium::pdfium_platform_library_name_at_path(path)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn tauri_localhost_is_tauri_asset_url() {
|
|
let https_url = tauri::Url::parse("https://tauri.localhost/index.html").unwrap();
|
|
let http_url = tauri::Url::parse("http://tauri.localhost/index.html").unwrap();
|
|
|
|
assert!(is_tauri_asset_url(&https_url));
|
|
assert!(is_tauri_asset_url(&http_url));
|
|
}
|
|
|
|
#[test]
|
|
fn localhost_app_url_is_not_tauri_asset_url() {
|
|
let url = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
|
|
assert!(!is_tauri_asset_url(&url));
|
|
assert!(is_local_http_url(&url));
|
|
}
|
|
|
|
#[test]
|
|
fn external_url_is_not_internal_url() {
|
|
let url = tauri::Url::parse("https://example.com/").unwrap();
|
|
|
|
assert!(!is_tauri_asset_url(&url));
|
|
assert!(!is_local_http_url(&url));
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|