Merge branch 'main' into chunk-data

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

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
+1124 -250
View File
File diff suppressed because it is too large. Load diff
+39 -14
View File
@@ -1,15 +1,15 @@
[package]
name = "mindwork-ai-studio"
version = "26.6.2"
version = "26.7.3"
edition = "2024"
description = "MindWork AI Studio"
authors = ["Thorsten Sommer"]
[build-dependencies]
tauri-build = { version = "2.6.2", features = [] }
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
tauri = { version = "2.11.2", features = [] }
tauri = { version = "2.11.5", features = [] }
tauri-plugin-window-state = { version = "2.4.1" }
tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.1"
@@ -18,18 +18,19 @@ tauri-plugin-single-instance = "2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
keyring-core = "1.0.0"
arboard = "3.6.1"
arboard = { version = "3.6.1", features = ["wayland-data-control"] }
tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "macros", "process"] }
tokio-stream = "0.1.18"
tokio-stream = { version = "0.1.18", features = ["sync"] }
futures = "0.3.32"
async-stream = "0.3.6"
flexi_logger = "0.31.9"
log = { version = "0.4.30", features = ["kv"] }
dirs = "6.0.0"
log = { version = "0.4.33", features = ["kv"] }
once_cell = "1.21.4"
axum = { version = "0.8.9", features = ["http2", "json", "query", "tokio"] }
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
rustls = { version = "0.23.28", default-features = false, features = ["aws_lc_rs"] }
rand = "0.10.1"
rand = "0.10.2"
rand_chacha = "0.10.0"
base64 = "0.22.1"
aes = "0.9.1"
@@ -39,18 +40,22 @@ hmac = "0.13.0"
sha2 = "0.11.0"
rcgen = { version = "0.14.8", features = ["pem"] }
file-format = "0.29.0"
calamine = "0.35.0"
symphonia = { version = "0.6", default-features = false, features = ["aac", "aiff", "alac", "caf", "flac", "isomp4", "mkv", "mp1", "mp2", "mp3", "ogg", "pcm", "vorbis", "wav"] }
ropus = "=0.12.18"
rubato = { version = "4", default-features = false, features = ["fft_resampler"] }
webm-iterable = "0.6.4"
calamine = "0.36.0"
pdfium-render = "0.9.1"
sys-locale = "0.3.2"
whoami = "2.1.2"
cfg-if = "1.0.4"
pptx-to-md = "0.4.0"
pptx-to-md = "1.0.0"
tempfile = "3.27.0"
strum_macros = "0.28.0"
sysinfo = "0.39.3"
bytes = "1.11.1"
tokenizers = "0.23.1"
sysinfo = "0.39.6"
bytes = "1.12.1"
qdrant-edge = "0.7.2"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
[patch.crates-io]
# Issue: It was not possible to build qdrant-edge for macOS. See PR 9312: https://github.com/qdrant/qdrant/pull/9312
@@ -63,17 +68,37 @@ permutation_iterator = { git = "https://github.com/SommerEngineering/permutation
[target.'cfg(target_os = "windows")'.dependencies]
windows-registry = "0.6.1"
windows-native-keyring-store = "1.0.0"
windows-native-keyring-store = "1.1.0"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri", "global_shortcuts"] }
dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
dbus-secret-service = "4.1.0"
webkit2gtk = { version = "2.0.2", features = ["v2_8"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2.10.0"
tauri-plugin-updater = "2.10.1"
[features]
custom-protocol = ["tauri/custom-protocol"]
# Media normalization is CPU-heavy even when the application itself is built for development.
# Keep release settings untouched while optimizing the hot decoder/resampler/container crates.
[profile.dev.package.symphonia-core]
opt-level = 3
[profile.dev.package.symphonia-format-mkv]
opt-level = 3
[profile.dev.package.ropus]
opt-level = 3
[profile.dev.package.rubato]
opt-level = 3
[profile.dev.package.webm-iterable]
opt-level = 3
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Version=1.5
Name=MindWork AI Studio
GenericName=AI Studio
Comment=MindWork AI Studio is a free, independent cross-platform desktop app for local and cloud LLMs across providers, built to democratize AI access.
Keywords=AI;LLM;Assistant;
Exec=mind-work-ai-studio
TryExec=mind-work-ai-studio
Icon=org.mindworkai.AIStudio
Categories=Science;Utility;Office;
SingleMainWindow=true
DBusActivatable=false
StartupWMClass=org.mindworkai.AIStudio
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.mindworkai.AIStudio</id>
<name>MindWork AI Studio</name>
<project_license>FSL-1.1-MIT</project_license>
<metadata_license>MIT</metadata_license>
<summary>MindWork AI Studio is a free, independent cross-platform desktop app for local and cloud LLMs across providers, built to democratize AI access.</summary>
<developer id="org.mindworkai">
<name>MindWork AI Community</name>
</developer>
<content_rating type="oars-1.1" />
<description>
<p>
MindWork AI Studio is a free desktop app for macOS, Windows, and Linux. It provides a unified user interface
for interaction with Large Language Models (LLM). AI Studio also offers so-called assistants, where prompting
is not necessary. You can think of AI Studio like an email program: you bring your own API key for the LLM of
your choice and can then use these AI systems with AI Studio.
</p>
<p>Key advantages:</p>
<ul>
<li>
Free of charge: The app is free to use, both for personal and commercial purposes.
</li>
<li>
Democratization of AI: MindWork AI Studio runs even on low-cost hardware, including
computers such as Raspberry Pi. This makes the app and its full feature set accessible
to people and families with limited budgets. You can start with local LLMs or use
affordable cloud models.
</li>
<li>
Independence: You are not tied to any single provider. Choose the providers that best
suit your needs, including OpenAI, Perplexity, Mistral, Anthropic, Google Gemini, xAI,
DeepSeek, Alibaba Cloud, OpenRouter, Hugging Face, Groq, Fireworks, Helmholtz, GWDG,
and self-hosted models.
</li>
<li>
Assistants: Use ready-made assistants for common business and other tasks without writing prompts yourself.
</li>
<li>
Unrestricted usage: Unlike services that impose limits after intensive use, MindWork
AI Studio lets you use provider APIs without restrictions imposed by the app.
</li>
<li>
Cost-effective: You only pay providers for what you use, which can be cheaper than a
monthly subscription when used infrequently. For intensive usage, API costs may be
higher, so you should monitor your provider accounts and use prepaid credit or cost
limits when available.
</li>
<li>
Privacy: Control which providers receive your data using provider confidence settings
and assign different protection levels to different tasks.
</li>
<li>
Flexibility: Choose the provider and model best suited to your current task.
</li>
<li>
No bloatware: The app requires little storage and memory and has minimal impact on
system resources and battery life.
</li>
</ul>
</description>
<launchable type="desktop-id">org.mindworkai.AIStudio.desktop</launchable>
<categories>
<category>Utility</category>
<category>Office</category>
<category>Science</category>
</categories>
<keywords>
<keyword>AI</keyword>
<keyword>Assistant</keyword>
<keyword>Privacy</keyword>
</keywords>
<url type="homepage">https://mindworkai.org</url>
<url type="bugtracker">https://github.com/MindWorkAI/AI-Studio/issues</url>
<url type="contact">https://github.com/MindWorkAI</url>
<url type="contribute">https://github.com/MindWorkAI/AI-Studio#contributing-ov-file</url>
<url type="vcs-browser">https://github.com/MindWorkAI/AI-Studio</url>
<provides>
<binary>mind-work-ai-studio</binary>
</provides>
<branding>
<color type="primary" scheme_preference="light">#b4bed5</color>
<color type="primary" scheme_preference="dark">#707e99</color>
</branding>
<screenshots>
<screenshot type="default">
<image>https://github.com/MindWorkAI/AI-Studio/blob/main/documentation/AI%20Studio%20Home.png?raw=true</image>
<caption>Getting started</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/MindWorkAI/AI-Studio/refs/heads/main/documentation/AI%20Studio%20Assistants.png</image>
<caption>Assistants</caption>
</screenshot>
</screenshots>
<releases>
<release type="stable" version="26.7.3" date="2026-07-21">
<description>
<p>Update</p>
</description>
</release>
</releases>
</component>
@@ -0,0 +1,184 @@
# Media pipeline third-party notices
These notices are bundled offline with MindWork AI Studio.
## Symphonia 0.6.0
Copyright (c) 2019-2026 The Project Symphonia Developers.
MindWork AI Studio uses the unmodified Symphonia 0.6.0 crates. The exact corresponding source is:
- https://github.com/pdeljanov/Symphonia/tree/v0.6.0
- https://crates.io/api/v1/crates/symphonia/0.6.0/download
If a future AI Studio release modifies MPL-covered Symphonia files, those modifications must be identified and made available separately under MPL-2.0. No such modifications are present in this release.
Mozilla Public License Version 2.0
1. Definitions
1.1. “Contributor” means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software.
1.2. “Contributor Version” means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution.
1.3. “Contribution” means Covered Software of a particular Contributor.
1.4. “Covered Software” means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof.
1.5. “Incompatible With Secondary Licenses” means that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License.
1.6. “Executable Form” means any form of the work other than Source Code Form.
1.7. “Larger Work” means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software.
1.8. “License” means this document.
1.9. “Licensable” means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License.
1.10. “Modifications” means any of the following: any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or any new file in Source Code Form that contains any Covered Software.
1.11. “Patent Claims” of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version.
1.12. “Secondary License” means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses.
1.13. “Source Code Form” means the form of the work preferred for making modifications.
1.14. “You” means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. “Control” means ownership of more than fifty percent of the outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants. Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version.
2.2. Effective Date. The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution.
2.3. Limitations on Grant Scope. No license is granted in the trademarks, service marks, or logos of any Contributor. Except as otherwise provided in this License, no Contributor grants additional rights by implication, estoppel, or otherwise.
2.4. Subsequent Licenses. No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License or the terms of a Secondary License.
2.5. Representation. Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use. This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents.
2.7. Conditions. Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form. All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form.
3.2. Distribution of Executable Form. If You distribute Covered Software in Executable Form then such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work. You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s).
3.4. Notices. You may not remove or alter the substance of any license notices contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. You must include a copy of this License with every copy of the Covered Software You distribute. You may add additional accurate notices of copyright ownership.
3.5. Application of Additional Terms. You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must comply with the terms of this License to the maximum extent possible and describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License.
5. Termination
5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent infringement claim alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 will terminate.
5.3. In the event of termination under Sections 5.1 or 5.2, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort, contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages.
8. Litigation
Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject matter hereof. If any provision is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor.
10. Versions of the License
10.1. New Versions. Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License.
10.2. Effect of New Versions. You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward.
10.3. Modified Versions. If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses. If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
Exhibit B - “Incompatible With Secondary Licenses” Notice
This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0.
## Ropus 0.12.18
Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon
Copyright (c) 2026 Martin Davidson (Rust port additions)
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
- Neither the name of Internet Society, IETF or IETF Trust, nor the names of specific contributors, may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Opus is subject to the royalty-free patent licenses specified at:
- Xiph.Org Foundation: https://datatracker.ietf.org/ipr/1524/
- Microsoft Corporation: https://datatracker.ietf.org/ipr/1914/
- Broadcom Corporation: https://datatracker.ietf.org/ipr/1526/
## Rubato 4.0.0 (MIT option)
Copyright (c) 2020 Henrik Enquist
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## Apache ECharts 6.1.0 common
Copyright 2017-2026 The Apache Software Foundation
Apache ECharts is licensed under the Apache License, Version 2.0:
https://www.apache.org/licenses/LICENSE-2.0
The bundled distribution includes zrender and other BSD-licensed subcomponents.
Their copyright and license notices are preserved in the bundled
`echarts.common.min.js` file and in the Apache ECharts distribution:
https://github.com/apache/echarts/tree/6.1.0/licenses
## image 0.25.10
Copyright image-rs developers
Licensed, at your option, under either the Apache License, Version 2.0 or the
MIT License:
- https://www.apache.org/licenses/LICENSE-2.0
- https://github.com/image-rs/image/blob/v0.25.10/LICENSE-MIT
## webm-iterable 0.6.4
MIT License
Copyright (c) 2021 Austin Blake
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+201 -243
View File
@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -13,25 +12,34 @@ use log::{debug, error, info, trace, warn};
use once_cell::sync::Lazy;
use pdfium_render::prelude::Pdfium;
use serde::{Deserialize, Serialize};
use strum_macros::Display;
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context};
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
use tauri::path::PathResolver;
use tauri::WebviewWindow;
use tauri_plugin_updater::{UpdaterExt, Update};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_opener::OpenerExt;
use tokio::sync::broadcast;
use tokio::time;
use crate::api_token::APIToken;
use crate::clipboard::shutdown_clipboard;
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY};
use crate::environment::{
is_prod, is_dev, is_flatpak, CONFIG_DIRECTORY, DATA_DIRECTORY, FLATPAK_LIBRARY_DIRECTORY,
};
use crate::log::switch_to_file_logging;
use crate::pdfium::PDFIUM_LIB_PATH;
use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database};
use crate::global_shortcuts::{RegisterShortcutRequest, ShortcutResponse};
#[cfg(debug_assertions)]
use crate::dotnet::create_startup_env_file;
use crate::tokenizer::set_default_tokenizer_path;
#[cfg(target_os = "linux")]
use webkit2gtk::glib::Cast;
#[cfg(target_os = "linux")]
use webkit2gtk::{PermissionRequestExt, UserMediaPermissionRequestExt};
/// The Tauri main window.
pub static MAIN_WINDOW: Lazy<Mutex<Option<WebviewWindow>>> = Lazy::new(|| Mutex::new(None));
@@ -41,22 +49,11 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::
/// The event broadcast sender for Tauri events.
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
/// Stores the currently registered global shortcuts (name -> shortcut string).
static REGISTERED_SHORTCUTS: Lazy<Mutex<HashMap<Shortcut, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Stores the localhost origin of the Blazor app after the .NET server is ready.
static APPROVED_APP_URL: Lazy<Mutex<Option<tauri::Url>>> = Lazy::new(|| Mutex::new(None));
/// Enum identifying global keyboard shortcuts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum Shortcut {
None = 0,
VoiceRecordingToggle,
}
/// Starts the Tauri app.
pub fn start_tauri() {
pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
info!("Starting Tauri app...");
// Create the event broadcast channel:
@@ -137,6 +134,9 @@ pub fn start_tauri() {
// Get the main window:
let window = app.get_webview_window("main").expect("Failed to get main window.");
#[cfg(target_os = "linux")]
register_linux_permission_request_handler(&window);
// Register a callback for window events, such as file drops. We have to use
// this handler in addition to the app event handler, because file drop events
// are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338):
@@ -182,7 +182,7 @@ pub fn start_tauri() {
Ok(())
})
.plugin(tauri_plugin_window_state::Builder::default().build())
.build(generate_context!())
.build(tauri_context)
.expect("Error while running Tauri application");
// The app event handler:
@@ -208,6 +208,7 @@ pub fn start_tauri() {
RunEvent::ExitRequested { .. } => {
warn!(Source = "Tauri"; "Run event: exit was requested.");
shutdown_clipboard();
stop_qdrant_edge_database();
if is_prod() {
warn!("Try to stop the .NET server as well...");
@@ -248,6 +249,69 @@ fn same_origin(left: &tauri::Url, right: &tauri::Url) -> bool {
&& left.port_or_known_default() == right.port_or_known_default()
}
#[cfg(any(target_os = "linux", test))]
fn should_allow_audio_capture(
approved_app_url: Option<&tauri::Url>,
current_webview_url: Option<&tauri::Url>,
requests_audio: bool,
requests_video: bool,
) -> bool {
requests_audio
&& !requests_video
&& approved_app_url.is_some_and(is_local_http_url)
&& approved_app_url
.zip(current_webview_url)
.is_some_and(|(approved, current)| same_origin(approved, current))
}
#[cfg(target_os = "linux")]
fn register_linux_permission_request_handler(window: &WebviewWindow) {
if let Err(error) = window.with_webview(|platform_webview| {
use webkit2gtk::WebViewExt;
use webkit2gtk::UserMediaPermissionRequest;
let webview = platform_webview.inner();
webview.connect_permission_request(|webview, request| {
let Some(user_media_request) = request.downcast_ref::<UserMediaPermissionRequest>() else {
request.deny();
info!(Source = "Tauri"; "Denied a non-user-media WebKit permission request.");
return true;
};
let current_webview_url = webview
.uri()
.and_then(|uri| tauri::Url::parse(uri.as_str()).ok());
let approved_app_url = APPROVED_APP_URL.lock().unwrap().clone();
let origin_matches = approved_app_url
.as_ref()
.zip(current_webview_url.as_ref())
.is_some_and(|(approved, current)| same_origin(approved, current));
let requests_audio = user_media_request.is_for_audio_device();
let requests_video = user_media_request.is_for_video_device();
let allow = should_allow_audio_capture(
approved_app_url.as_ref(),
current_webview_url.as_ref(),
requests_audio,
requests_video,
);
if allow {
request.allow();
} else {
request.deny();
}
info!(
Source = "Tauri";
"Handled WebKit user-media permission request: allowed={allow}, origin_matches={origin_matches}, audio={requests_audio}, video={requests_video}."
);
true
});
}) {
error!(Source = "Tauri"; "Failed to register the Linux WebKit permission request handler: {error}");
}
}
fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>, url: &tauri::Url) -> bool {
match url.scheme() {
"mailto" | "tel" => return true,
@@ -412,6 +476,7 @@ pub enum TauriEventType {
FileDropCanceled,
GlobalShortcutPressed,
GlobalShortcutChanged,
}
/// Changes the location of the main window to the given URL.
@@ -452,8 +517,9 @@ pub async fn change_location_to(url: &str) {
/// Checks for updates.
pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
if is_dev() {
warn!(Source = "Updater"; "The app is running in development mode; skipping update check.");
if !self_update_allowed(is_dev(), is_flatpak()) {
let reason = if is_flatpak() { "Flatpak installations are updated externally" } else { "the app is running in development mode" };
warn!(Source = "Updater"; "Skipping update check because {reason}.");
return Json(CheckUpdateResponse {
update_is_available: false,
error: false,
@@ -537,8 +603,9 @@ pub struct CheckUpdateResponse {
/// Installs the update.
pub async fn install_update(_token: APIToken) {
if is_dev() {
warn!(Source = "Updater"; "The app is running in development mode; skipping update installation.");
if !self_update_allowed(is_dev(), is_flatpak()) {
let reason = if is_flatpak() { "Flatpak installations are updated externally" } else { "the app is running in development mode" };
warn!(Source = "Updater"; "Skipping update installation because {reason}.");
return;
}
@@ -596,22 +663,8 @@ pub async fn install_update(_token: APIToken) {
}
}
/// Request payload for registering a global shortcut.
#[derive(Clone, Deserialize)]
pub struct RegisterShortcutRequest {
/// The shortcut ID to use.
id: Shortcut,
/// The shortcut string in Tauri format (e.g., "CmdOrControl+1").
/// Use empty string to unregister the shortcut.
shortcut: String,
}
/// Response for shortcut registration.
#[derive(Serialize)]
pub struct ShortcutResponse {
success: bool,
error_message: String,
fn self_update_allowed(development: bool, flatpak: bool) -> bool {
!development && !flatpak
}
/// Response for application exit requests.
@@ -621,28 +674,6 @@ pub struct AppExitResponse {
error_message: String,
}
/// Internal helper function to register a shortcut with its callback.
/// This is used by both `register_shortcut` and `resume_shortcuts` to
/// avoid code duplication.
fn register_shortcut_with_callback<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
shortcut: &str,
shortcut_id: Shortcut,
event_sender: broadcast::Sender<Event>,
) -> Result<(), tauri_plugin_global_shortcut::Error> {
let shortcut_manager = app_handle.global_shortcut();
shortcut_manager.on_shortcut(shortcut, move |_app, _shortcut, _event| {
info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id);
let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_id.to_string()]);
let sender = event_sender.clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = sender.send(event) {
error!(Source = "Tauri"; "Failed to send global shortcut event: {error}");
}
});
})
}
/// Requests a controlled shutdown of the entire desktop application.
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
let app_handle = {
@@ -674,89 +705,9 @@ pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
/// Registers or updates a global shortcut. If the shortcut string is empty,
/// the existing shortcut for that name will be unregistered.
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
let id = payload.id;
let new_shortcut = payload.shortcut.clone();
if id == Shortcut::None {
error!(Source = "Tauri"; "Cannot register NONE shortcut.");
return Json(ShortcutResponse {
success: false,
error_message: "Cannot register NONE shortcut".to_string(),
});
}
info!(Source = "Tauri"; "Registering global shortcut '{}' with key '{new_shortcut}'.", id);
// Get the main window to access the global shortcut manager:
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot register shortcut: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let app_handle = main_window.app_handle();
let shortcut_manager = app_handle.global_shortcut();
let mut registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Unregister the old shortcut if one exists for this name:
if let Some(old_shortcut) = registered_shortcuts.get(&id) && !old_shortcut.is_empty() {
match shortcut_manager.unregister(old_shortcut.as_str()) {
Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id),
Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"),
}
}
// When the new shortcut is empty, we're done (just unregistering):
if new_shortcut.is_empty() {
registered_shortcuts.remove(&id);
info!(Source = "Tauri"; "Shortcut '{}' has been disabled.", id);
return Json(ShortcutResponse {
success: true,
error_message: String::new(),
});
}
// Get the event broadcast sender for the shortcut callback:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
let event_sender = match event_broadcast_lock.as_ref() {
Some(sender) => sender.clone(),
None => {
error!(Source = "Tauri"; "Cannot register shortcut: event broadcast not initialized.");
return Json(ShortcutResponse {
success: false,
error_message: "Event broadcast not initialized".to_string(),
});
}
};
drop(event_broadcast_lock);
// Register the new shortcut:
match register_shortcut_with_callback(app_handle, &new_shortcut, id, event_sender) {
Ok(_) => {
info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id);
registered_shortcuts.insert(id, new_shortcut);
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
},
Err(error) => {
let error_msg = format!("Failed to register shortcut: {error}");
error!(Source = "Tauri"; "{error_msg}");
Json(ShortcutResponse {
success: false,
error_message: error_msg,
})
}
}
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await)
}
/// Request payload for validating a shortcut.
@@ -792,8 +743,7 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
}
// Check if the shortcut is already registered:
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
for (name, registered_shortcut) in registered_shortcuts.iter() {
for (name, registered_shortcut) in crate::global_shortcuts::registered_shortcuts().await {
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
return Json(ShortcutValidationResponse {
is_valid: true,
@@ -804,8 +754,6 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
}
}
drop(registered_shortcuts);
// Try to parse the shortcut to validate syntax.
// We can't easily validate without registering in Tauri 1.x,
// so we do basic syntax validation here:
@@ -828,100 +776,20 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
}
}
/// Suspends shortcut processing by unregistering all shortcuts from the OS.
/// The shortcuts remain in our internal map, so they can be re-registered on resume.
/// Suspends shortcut processing. Portal sessions remain active and ignore activations;
/// Tauri shortcuts are temporarily unregistered and restored on resume.
/// This is useful when opening a dialog to configure shortcuts, so the user can
/// press the current shortcut to re-enter it without triggering the action.
pub async fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
// Get the main window to access the global shortcut manager:
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot suspend shortcuts: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let app_handle = main_window.app_handle();
let shortcut_manager = app_handle.global_shortcut();
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Unregister all shortcuts from the OS (but keep them in our map):
for (name, shortcut) in registered_shortcuts.iter() {
if !shortcut.is_empty() {
match shortcut_manager.unregister(shortcut.as_str()) {
Ok(_) => info!(Source = "Tauri"; "Temporarily unregistered shortcut '{shortcut}' for '{}'.", name),
Err(error) => warn!(Source = "Tauri"; "Failed to unregister shortcut '{shortcut}' for '{}': {error}", name),
}
}
}
info!(Source = "Tauri"; "Shortcut processing has been suspended ({} shortcuts unregistered).", registered_shortcuts.len());
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
Json(crate::global_shortcuts::suspend(app_handle).await)
}
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
pub async fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
// Get the main window to access the global shortcut manager:
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot resume shortcuts: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let app_handle = main_window.app_handle();
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Get the event broadcast sender for the shortcut callbacks:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
let event_sender = match event_broadcast_lock.as_ref() {
Some(sender) => sender.clone(),
None => {
error!(Source = "Tauri"; "Cannot resume shortcuts: event broadcast not initialized.");
return Json(ShortcutResponse {
success: false,
error_message: "Event broadcast not initialized".to_string(),
});
}
};
drop(event_broadcast_lock);
// Re-register all shortcuts with the OS:
let mut success_count = 0;
for (shortcut_id, shortcut) in registered_shortcuts.iter() {
if shortcut.is_empty() {
continue;
}
match register_shortcut_with_callback(app_handle, shortcut, *shortcut_id, event_sender.clone()) {
Ok(_) => {
info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id);
success_count += 1;
},
Err(error) => warn!(Source = "Tauri"; "Failed to re-register shortcut '{shortcut}' for '{}': {error}", shortcut_id),
}
}
info!(Source = "Tauri"; "Shortcut processing has been resumed ({success_count} shortcuts re-registered).");
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::resume(app_handle, event_sender).await)
}
/// Validates the syntax of a shortcut string.
@@ -977,7 +845,7 @@ fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
}
};
match select_pdfium_library_directory(&resource_dir) {
match select_pdfium_library_directory(&resource_dir, is_flatpak()) {
Some(path) => {
*PDFIUM_LIB_PATH.lock().unwrap() = Some(path.to_string_lossy().to_string());
}
@@ -987,11 +855,23 @@ fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
}
}
fn select_pdfium_library_directory(resource_dir: &Path) -> Option<PathBuf> {
let candidate_paths = [
resource_dir.join("resources").join("libraries"),
resource_dir.join("libraries"),
];
fn select_pdfium_library_directory(resource_dir: &Path, include_flatpak_library_directory: bool) -> Option<PathBuf> {
select_pdfium_library_directory_for(resource_dir, include_flatpak_library_directory, Path::new(FLATPAK_LIBRARY_DIRECTORY))
}
fn select_pdfium_library_directory_for(
resource_dir: &Path,
include_flatpak_library_directory: bool,
flatpak_library_directory: &Path,
) -> Option<PathBuf> {
let mut candidate_paths = Vec::new();
if include_flatpak_library_directory {
candidate_paths.push(flatpak_library_directory.to_path_buf());
}
candidate_paths.push(resource_dir.join("resources").join("libraries"));
candidate_paths.push(resource_dir.join("libraries"));
for path in candidate_paths {
let pdfium_library_path = Pdfium::pdfium_platform_library_name_at_path(&path);
@@ -1016,6 +896,20 @@ mod tests {
use super::*;
use std::fs;
#[test]
fn self_update_is_disabled_in_development() {
assert!(!self_update_allowed(true, false));
}
#[test]
fn self_update_is_disabled_for_flatpak() {
assert!(!self_update_allowed(false, true));
}
#[test]
fn self_update_is_enabled_for_normal_production_installations() {
assert!(self_update_allowed(false, false));
}
#[test]
fn pdfium_library_directory_prefers_resources_libraries() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -1025,7 +919,7 @@ mod tests {
create_pdfium_library_in(&libraries);
assert_eq!(
select_pdfium_library_directory(temp_dir.path()),
select_pdfium_library_directory(temp_dir.path(), false),
Some(resources_libraries)
);
}
@@ -1039,7 +933,7 @@ mod tests {
create_pdfium_library_in(&libraries);
assert_eq!(
select_pdfium_library_directory(temp_dir.path()),
select_pdfium_library_directory(temp_dir.path(), false),
Some(libraries)
);
}
@@ -1050,7 +944,33 @@ mod tests {
fs::create_dir_all(temp_dir.path().join("resources").join("libraries")).unwrap();
fs::create_dir_all(temp_dir.path().join("libraries")).unwrap();
assert_eq!(select_pdfium_library_directory(temp_dir.path()), None);
assert_eq!(select_pdfium_library_directory(temp_dir.path(), false), None);
}
#[test]
fn pdfium_library_directory_prefers_flatpak_library_directory_when_flatpak() {
let temp_dir = tempfile::tempdir().unwrap();
let flatpak_library_directory = temp_dir.path().join("app").join("lib");
let resources_libraries = temp_dir.path().join("resources").join("libraries");
create_pdfium_library_in(&flatpak_library_directory);
create_pdfium_library_in(&resources_libraries);
assert_eq!(
select_pdfium_library_directory_for(temp_dir.path(), true, &flatpak_library_directory),
Some(flatpak_library_directory)
);
}
#[test]
fn pdfium_library_directory_skips_flatpak_library_directory_when_not_flatpak() {
let temp_dir = tempfile::tempdir().unwrap();
let flatpak_library_directory = temp_dir.path().join("app").join("lib");
create_pdfium_library_in(&flatpak_library_directory);
assert_eq!(
select_pdfium_library_directory_for(temp_dir.path(), false, &flatpak_library_directory),
None
);
}
fn create_pdfium_library_in(path: &Path) {
@@ -1082,4 +1002,42 @@ mod tests {
assert!(!is_tauri_asset_url(&url));
assert!(!is_local_http_url(&url));
}
#[test]
fn audio_capture_is_allowed_for_exact_approved_app_origin() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
let current = tauri::Url::parse("http://localhost:12345/voice-recorder").unwrap();
assert!(should_allow_audio_capture(Some(&approved), Some(&current), true, false));
}
#[test]
fn audio_capture_is_denied_for_wrong_port() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
let current = tauri::Url::parse("http://localhost:54321/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&current), true, false));
}
#[test]
fn audio_capture_is_denied_for_external_origin() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
let current = tauri::Url::parse("https://example.com/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&current), true, false));
}
#[test]
fn video_capture_is_denied() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&approved), false, true));
}
#[test]
fn combined_audio_and_video_capture_is_denied() {
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
assert!(!should_allow_audio_capture(Some(&approved), Some(&approved), true, true));
}
}
+251 -16
View File
@@ -1,10 +1,80 @@
use std::fmt::Display;
use std::sync::Mutex;
use arboard::Clipboard;
use log::{debug, error};
use axum::Json;
use log::{debug, error, warn};
use once_cell::sync::Lazy;
use serde::Serialize;
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// The process-wide clipboard instance. On Linux, retaining this instance keeps the app's
/// ownership of clipboard contents alive until the next write or application shutdown.
static CLIPBOARD: Lazy<Mutex<Option<Clipboard>>> = Lazy::new(|| Mutex::new(None));
trait ClipboardBackend {
type Error: Display;
fn set_text(&mut self, text: String) -> Result<(), Self::Error>;
}
impl ClipboardBackend for Clipboard {
type Error = arboard::Error;
fn set_text(&mut self, text: String) -> Result<(), Self::Error> {
Clipboard::set_text(self, text)
}
}
#[derive(Debug, PartialEq, Eq)]
enum ClipboardOperationError<E> {
Initialization(E),
Write(E),
}
impl<E: Display> Display for ClipboardOperationError<E> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Initialization(error) => write!(formatter, "Failed to initialize the clipboard backend: {error}"),
Self::Write(error) => write!(formatter, "Failed to write to the clipboard: {error}"),
}
}
}
fn set_text_with_retry<B, F>(
clipboard: &mut Option<B>,
text: String,
mut create_clipboard: F,
) -> Result<(), ClipboardOperationError<B::Error>>
where
B: ClipboardBackend,
F: FnMut() -> Result<B, B::Error>,
{
if clipboard.is_none() {
*clipboard = Some(create_clipboard().map_err(ClipboardOperationError::Initialization)?);
}
let first_result = clipboard.as_mut().unwrap().set_text(text.clone());
if let Err(first_error) = first_result {
warn!(Source = "Clipboard"; "Failed to set text using the current clipboard backend; reinitializing it once: {first_error}.");
*clipboard = None;
let mut retry_clipboard = create_clipboard().map_err(ClipboardOperationError::Initialization)?;
if let Err(retry_error) = retry_clipboard.set_text(text) {
error!(Source = "Clipboard"; "Failed to set text after reinitializing the clipboard backend: {retry_error}.");
return Err(ClipboardOperationError::Write(retry_error));
}
*clipboard = Some(retry_clipboard);
}
Ok(())
}
fn release_clipboard<B>(clipboard: &mut Option<B>) -> bool {
clipboard.take().is_some()
}
/// Sets the clipboard text to the provided encrypted text.
pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<SetClipboardResponse> {
let encrypted_text = EncryptedText::new(encrypted_text);
@@ -21,20 +91,8 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
},
};
let clipboard_result = Clipboard::new();
let mut clipboard = match clipboard_result {
Ok(clipboard) => clipboard,
Err(e) => {
error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}.");
return Json(SetClipboardResponse {
success: false,
issue: e.to_string(),
})
},
};
let set_text_result = clipboard.set_text(decrypted_text);
match set_text_result {
let mut clipboard = CLIPBOARD.lock().unwrap();
match set_text_with_retry(&mut clipboard, decrypted_text, Clipboard::new) {
Ok(_) => {
debug!(Source = "Clipboard"; "Text was set to the clipboard successfully.");
Json(SetClipboardResponse {
@@ -44,7 +102,7 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
},
Err(e) => {
error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}.");
error!(Source = "Clipboard"; "Clipboard operation failed: {e}.");
Json(SetClipboardResponse {
success: false,
issue: e.to_string(),
@@ -53,9 +111,186 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
}
}
/// Releases the process-wide clipboard instance during application shutdown.
pub fn shutdown_clipboard() {
let mut clipboard = CLIPBOARD.lock().unwrap();
if release_clipboard(&mut clipboard) {
debug!(Source = "Clipboard"; "Clipboard instance was released.");
}
}
/// The response for setting the clipboard text.
#[derive(Serialize)]
pub struct SetClipboardResponse {
success: bool,
issue: String,
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::{ClipboardOperationError, release_clipboard, set_text_with_retry, ClipboardBackend};
struct MockClipboard {
id: usize,
fail_write: bool,
writes: Arc<Mutex<Vec<(usize, String)>>>,
drops: Arc<AtomicUsize>,
}
impl ClipboardBackend for MockClipboard {
type Error = String;
fn set_text(&mut self, text: String) -> Result<(), Self::Error> {
self.writes.lock().unwrap().push((self.id, text));
if self.fail_write {
Err(format!("backend {} failed", self.id))
} else {
Ok(())
}
}
}
impl Drop for MockClipboard {
fn drop(&mut self) {
self.drops.fetch_add(1, Ordering::SeqCst);
}
}
struct MockFactory {
outcomes: VecDeque<bool>,
created: usize,
writes: Arc<Mutex<Vec<(usize, String)>>>,
drops: Arc<AtomicUsize>,
}
impl MockFactory {
fn new(outcomes: impl IntoIterator<Item = bool>) -> Self {
Self {
outcomes: outcomes.into_iter().collect(),
created: 0,
writes: Arc::new(Mutex::new(Vec::new())),
drops: Arc::new(AtomicUsize::new(0)),
}
}
fn create(&mut self) -> Result<MockClipboard, String> {
let fail_write = self.outcomes.pop_front().expect("missing mock outcome");
let id = self.created;
self.created += 1;
Ok(MockClipboard {
id,
fail_write,
writes: Arc::clone(&self.writes),
drops: Arc::clone(&self.drops),
})
}
}
#[test]
fn initializes_lazily() {
let mut clipboard = None;
let mut factory = MockFactory::new([false]);
assert_eq!(factory.created, 0);
set_text_with_retry(&mut clipboard, "first".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 1);
assert!(clipboard.is_some());
}
#[test]
fn reports_initialization_failures_and_retries_on_the_next_request() {
let mut clipboard: Option<MockClipboard> = None;
let mut factory = MockFactory::new([false]);
let mut fail_initialization = true;
let error = set_text_with_retry(&mut clipboard, "first".to_string(), || {
if fail_initialization {
fail_initialization = false;
Err("initialization failed".to_string())
} else {
factory.create()
}
}).unwrap_err();
assert_eq!(error, ClipboardOperationError::Initialization("initialization failed".to_string()));
assert!(clipboard.is_none());
set_text_with_retry(&mut clipboard, "second".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 1);
assert!(clipboard.is_some());
}
#[test]
fn reuses_the_same_instance_for_multiple_writes() {
let mut clipboard = None;
let mut factory = MockFactory::new([false]);
set_text_with_retry(&mut clipboard, "first".to_string(), || factory.create()).unwrap();
set_text_with_retry(&mut clipboard, "second".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 1);
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "first".to_string()), (0, "second".to_string())]);
}
#[test]
fn retries_once_with_a_new_instance_after_a_write_failure() {
let mut clipboard = None;
let mut factory = MockFactory::new([true, false]);
set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap();
assert_eq!(factory.created, 2);
assert_eq!(clipboard.as_ref().unwrap().id, 1);
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "text".to_string()), (1, "text".to_string())]);
}
#[test]
fn reports_reinitialization_failures_and_discards_the_failed_instance() {
let mut clipboard = None;
let mut factory = MockFactory::new([true]);
let mut initialization_attempts = 0;
let error = set_text_with_retry(&mut clipboard, "text".to_string(), || {
initialization_attempts += 1;
if initialization_attempts == 1 {
factory.create()
} else {
Err("reinitialization failed".to_string())
}
}).unwrap_err();
assert_eq!(error, ClipboardOperationError::Initialization("reinitialization failed".to_string()));
assert_eq!(initialization_attempts, 2);
assert!(clipboard.is_none());
}
#[test]
fn returns_the_retry_error_and_discards_the_failed_instance() {
let mut clipboard = None;
let mut factory = MockFactory::new([true, true]);
let error = set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap_err();
assert_eq!(error, ClipboardOperationError::Write("backend 1 failed".to_string()));
assert_eq!(factory.created, 2);
assert!(clipboard.is_none());
}
#[test]
fn releases_the_instance_on_shutdown() {
let mut clipboard = None;
let mut factory = MockFactory::new([false]);
let drops = Arc::clone(&factory.drops);
set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap();
assert!(release_clipboard(&mut clipboard));
assert!(clipboard.is_none());
assert_eq!(drops.load(Ordering::SeqCst), 1);
}
}
+2
View File
@@ -31,6 +31,8 @@ pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS: &str = "AI_STUDIO_EX
#[cfg(any(target_os = "linux", test))]
const FLATPAK_ENTERPRISE_POLICY_DIRECTORY: &str = "/app/etc/MindWorkAI";
pub(crate) const FLATPAK_LIBRARY_DIRECTORY: &str = "/app/lib";
const ENTERPRISE_ENV_CONFIG_ID_PREFIX: &str = "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID";
const ENTERPRISE_ENV_CONFIG_SERVER_URL_PREFIX: &str = "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL";
const ENTERPRISE_ENV_CONFIGS: &str = "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS";
+337 -5
View File
@@ -2,10 +2,24 @@
use axum::extract::Query;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
use crate::api_token::APIToken;
use crate::app_window::MAIN_WINDOW;
#[cfg(any(windows, target_os = "macos"))]
use std::process::Command;
#[cfg(target_os = "linux")]
use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
/// Microsoft documents CREATE_NO_WINDOW as a process creation flag with value 0x08000000.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Clone, Deserialize)]
pub struct PreviousDirectory {
path: String,
@@ -32,10 +46,15 @@ pub struct SelectFileOptions {
#[derive(Clone, Deserialize)]
pub struct SaveFileOptions {
title: String,
name_file: Option<PreviousFile>,
previous_file: Option<PreviousFile>,
filter: Option<FileTypeFilter>,
}
#[derive(Clone, Deserialize)]
pub struct OpenPathOptions {
path: String,
}
#[derive(Serialize)]
pub struct DirectorySelectionResponse {
user_cancelled: bool,
@@ -60,6 +79,12 @@ pub struct FileSaveResponse {
save_file_path: String,
}
#[derive(Serialize)]
pub struct OpenPathResponse {
success: bool,
issue: String,
}
#[derive(Clone, Deserialize)]
pub struct PreviousFile {
file_path: String,
@@ -250,10 +275,15 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
// Set the file type filter if provided:
file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
if let Some(previous) = &payload.name_file {
let previous_path = previous.file_path.as_str();
file_dialog = file_dialog.set_directory(previous_path);
// Set the initial directory and file name if provided:
if let Some(previous) = &payload.previous_file {
let (directory, file_name) = split_save_file_path(&previous.file_path);
if let Some(directory) = directory {
file_dialog = file_dialog.set_directory(directory);
}
if let Some(file_name) = file_name {
file_dialog = file_dialog.set_file_name(file_name);
}
}
// Displays the file dialogue box and select the file:
@@ -286,6 +316,79 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
}
}
pub async fn open_path_in_file_manager(
_token: APIToken,
payload: Json<OpenPathOptions>,
) -> Json<OpenPathResponse> {
let requested_path = PathBuf::from(payload.path.trim());
if requested_path.as_os_str().is_empty() {
return Json(OpenPathResponse {
success: false,
issue: String::from("The path is empty."),
});
}
let Some(target) = resolve_file_manager_target(&requested_path) else {
let issue = format!(
"The path does not exist and its parent folder could not be found: {}",
requested_path.to_string_lossy(),
);
error!(Source = "Tauri"; "{issue}");
return Json(OpenPathResponse {
success: false,
issue,
});
};
#[cfg(target_os = "linux")]
{
return match open_path_in_linux_file_manager(&target).await {
Ok(()) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
Err(issue) => {
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
}
};
}
#[cfg(any(windows, target_os = "macos"))]
{
let mut command = create_file_manager_command(&target);
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
match command.spawn() {
Ok(_) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
Err(error) => {
let issue = format!("Failed to open the file manager: {error}");
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
}
}
}
}
/// Applies an optional file type filter to a FileDialogBuilder.
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
match filter {
@@ -296,4 +399,233 @@ fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &O
None => file_dialog,
}
}
fn split_save_file_path(file_path: &str) -> (Option<PathBuf>, Option<String>) {
let path = Path::new(file_path);
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf);
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty());
(directory, file_name)
}
#[derive(Debug, PartialEq, Eq)]
struct FileManagerTarget {
path: PathBuf,
reveal_file: bool,
}
#[cfg(any(target_os = "linux", test))]
#[derive(Debug, PartialEq, Eq)]
enum LinuxPortalOperation {
RevealFile,
OpenDirectory,
}
fn resolve_file_manager_target(requested_path: &Path) -> Option<FileManagerTarget> {
if requested_path.is_file() {
return Some(FileManagerTarget {
path: requested_path.to_path_buf(),
reveal_file: true,
});
}
if requested_path.is_dir() {
return Some(FileManagerTarget {
path: requested_path.to_path_buf(),
reveal_file: false,
});
}
requested_path.parent()
.filter(|parent| parent.is_dir())
.map(|parent| FileManagerTarget {
path: parent.to_path_buf(),
reveal_file: false,
})
}
#[cfg(any(target_os = "linux", test))]
fn linux_portal_operation(target: &FileManagerTarget) -> LinuxPortalOperation {
if target.reveal_file {
LinuxPortalOperation::RevealFile
} else {
LinuxPortalOperation::OpenDirectory
}
}
#[cfg(any(target_os = "linux", test))]
fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path {
if target.reveal_file {
target.path.parent().unwrap_or(&target.path)
} else {
&target.path
}
}
#[cfg(target_os = "linux")]
enum LinuxPortalError {
Unavailable(String),
RequestFailed(String),
}
#[cfg(target_os = "linux")]
async fn open_path_with_linux_portal(target: &FileManagerTarget) -> Result<(), LinuxPortalError> {
let file = std::fs::File::open(&target.path)
.map_err(|error| LinuxPortalError::Unavailable(format!("Failed to open the path for the desktop portal: {error}")))?;
let request = match linux_portal_operation(target) {
LinuxPortalOperation::RevealFile => OpenDirectoryRequest::default().send(&file).await,
LinuxPortalOperation::OpenDirectory => OpenFileRequest::default().send_file(&file).await,
}
.map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?;
request.response()
.map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}")))
}
#[cfg(target_os = "linux")]
async fn open_path_with_xdg_open(target: &FileManagerTarget) -> Result<(), String> {
let fallback_path = xdg_open_fallback_path(target);
let status = tokio::process::Command::new("xdg-open")
.arg(fallback_path)
.status()
.await
.map_err(|error| format!("xdg-open failed to start for '{}': {error}", fallback_path.to_string_lossy()))?;
if status.success() {
Ok(())
} else {
Err(format!("xdg-open failed for '{}' with exit status {status}", fallback_path.to_string_lossy()))
}
}
#[cfg(target_os = "linux")]
async fn open_path_in_linux_file_manager(target: &FileManagerTarget) -> Result<(), String> {
match open_path_with_linux_portal(target).await {
Ok(()) => Ok(()),
Err(LinuxPortalError::RequestFailed(error)) => Err(error),
Err(LinuxPortalError::Unavailable(portal_error)) => {
match open_path_with_xdg_open(target).await {
Ok(()) => Ok(()),
Err(fallback_error) => Err(format!("{portal_error} Fallback failed: {fallback_error}")),
}
}
}
}
#[cfg(target_os = "windows")]
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
let mut command = Command::new("explorer.exe");
if target.reveal_file {
command.arg(format!("/select,{}", target.path.to_string_lossy()));
} else {
command.arg(&target.path);
}
command
}
#[cfg(target_os = "macos")]
fn create_file_manager_command(target: &FileManagerTarget) -> Command {
let mut command = Command::new("open");
if target.reveal_file {
command.arg("-R");
}
command.arg(&target.path);
command
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn save_file_options_accept_the_previous_file_contract() {
let options: SaveFileOptions = serde_json::from_str(
r#"{"title":"Export visual briefing","previous_file":{"file_path":"Quarterly briefing.html"}}"#,
)
.unwrap();
assert_eq!(options.title, "Export visual briefing");
assert_eq!(
options.previous_file.unwrap().file_path,
"Quarterly briefing.html",
);
}
#[test]
fn save_file_name_without_directory_is_preserved() {
let (directory, file_name) = split_save_file_path("Quarterly briefing.html");
assert_eq!(directory, None);
assert_eq!(file_name.as_deref(), Some("Quarterly briefing.html"));
}
#[test]
fn save_file_path_is_split_into_directory_and_name() {
let temp_dir = tempfile::tempdir().unwrap();
let initial_path = temp_dir.path().join("Quarterly briefing.html");
let (directory, file_name) = split_save_file_path(initial_path.to_str().unwrap());
assert_eq!(directory.as_deref(), Some(temp_dir.path()));
assert_eq!(file_name.as_deref(), Some("Quarterly briefing.html"));
}
#[test]
fn existing_file_is_revealed_and_falls_back_to_its_parent() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("application.log");
fs::write(&file_path, "log").unwrap();
let target = resolve_file_manager_target(&file_path).unwrap();
assert_eq!(target.path, file_path);
assert!(target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::RevealFile);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn existing_directory_is_opened_directly() {
let temp_dir = tempfile::tempdir().unwrap();
let target = resolve_file_manager_target(temp_dir.path()).unwrap();
assert_eq!(target.path, temp_dir.path());
assert!(!target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn missing_file_uses_its_existing_parent_directory() {
let temp_dir = tempfile::tempdir().unwrap();
let missing_file = temp_dir.path().join("missing.log");
let target = resolve_file_manager_target(&missing_file).unwrap();
assert_eq!(target.path, temp_dir.path());
assert!(!target.reveal_file);
assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory);
assert_eq!(xdg_open_fallback_path(&target), temp_dir.path());
}
#[test]
fn invalid_path_without_existing_parent_is_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let invalid_path = temp_dir.path().join("missing-directory").join("missing.log");
assert!(resolve_file_manager_target(&invalid_path).is_none());
}
}
+155 -56
View File
@@ -12,7 +12,7 @@ use calamine::{open_workbook_auto, Reader};
use file_format::{FileFormat, Kind};
use futures::{Stream, StreamExt};
use pdfium_render::prelude::Pdfium;
use pptx_to_md::{ImageHandlingMode, ParserConfig, PptxContainer};
use pptx_to_md::{DiagnosticSeverity, ImageHandlingMode, MarkdownOptions, ParserConfig, PresentationContainer, PresentationFormat, PresentationMetadata, ReadingOrder};
use serde::{Deserialize, Deserializer, Serialize};
use serde::de::{Error as SerdeError, Visitor};
use std::path::Path;
@@ -190,10 +190,14 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
},
};
let ext = file_path.split('.').next_back().unwrap_or("");
let ext = Path::new(file_path)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
debug!("Extracting data from file: '{file_path}', format: '{fmt:?}', extension: '{ext}'");
let stream = match ext {
let stream = match ext.as_str() {
DOCX | ODT => {
let from = if ext == DOCX { "docx" } else { "odt" };
convert_with_pandoc(file_path, from, TO_MARKDOWN).await?
@@ -203,7 +207,8 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
stream_text_file(file_path, true, Some("csv".to_string())).await?
},
"pptx" => stream_pptx(file_path, extract_images).await?,
"pptx" => stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?,
"odp" => stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?,
"xlsx" | "ods" | "xls" | "xlsm" | "xlsb" | "xla" | "xlam" => {
stream_spreadsheet_as_csv(file_path).await?
@@ -244,8 +249,11 @@ async fn stream_data(file_path: &str, extract_images: bool) -> Result<ChunkStrea
Kind::Presentation => match fmt {
FileFormat::OfficeOpenXmlPresentation => {
stream_pptx(file_path, extract_images).await?
stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?
},
FileFormat::OpendocumentPresentation => {
stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?
}
_ => stream_text_file(file_path, false, None).await?,
},
@@ -448,7 +456,7 @@ async fn chunk_image(file_path: &str) -> Result<ChunkStream> {
Ok(Box::pin(stream))
}
async fn stream_pptx(file_path: &str, extract_images: bool) -> Result<ChunkStream> {
async fn stream_presentation(file_path: &str, extract_images: bool, format: PresentationFormat) -> Result<ChunkStream> {
let path = Path::new(file_path).to_owned();
let parser_config = ParserConfig::builder()
@@ -456,76 +464,167 @@ async fn stream_pptx(file_path: &str, extract_images: bool) -> Result<ChunkStrea
.compress_images(true)
.quality(75)
.image_handling_mode(ImageHandlingMode::Manually)
.include_presentation_metadata(true)
.build();
let markdown_options = MarkdownOptions {
reading_order: ReadingOrder::Spatial,
include_slide_number_as_comment: true,
include_speaker_notes: true,
include_comments: true,
render_unsupported_comments: true,
};
let mut streamer = tokio::task::spawn_blocking(move || {
PptxContainer::open(&path, parser_config).map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
PresentationContainer::open_as(&path, parser_config, format).map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
}).await??;
let (tx, rx) = mpsc::channel(32);
let worker_error_tx = tx.clone();
// Slide iteration performs synchronous ZIP/XML work and image compression,
// so the complete producer must stay outside Tokio's asynchronous workers.
let worker = tokio::task::spawn_blocking(move || {
let mut metadata_md = presentation_metadata_to_markdown(streamer.metadata());
tokio::spawn(async move {
for slide_result in streamer.iter_slides() {
match slide_result {
Ok(slide) => {
if let Some(md_content) = slide.convert_to_md() {
let slide = match slide_result {
Ok(slide) => slide,
Err(e) => {
let _ = tx.blocking_send(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>));
return;
},
};
for diagnostic in &slide.diagnostics {
let source = diagnostic.source.as_deref().unwrap_or("presentation");
match diagnostic.severity {
DiagnosticSeverity::Warning => warn!(
"Presentation slide {} warning in '{}': {}",
slide.slide_number,
source,
diagnostic.message
),
DiagnosticSeverity::Error => error!(
"Presentation slide {} error in '{}': {}",
slide.slide_number,
source,
diagnostic.message
),
}
}
let mut content = match slide.to_markdown(&markdown_options) {
Ok(content) => content,
Err(e) => {
let _ = tx.blocking_send(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>));
return;
},
};
if let Some(metadata) = metadata_md.take() {
content = format!("{metadata}\n\n{content}");
}
let chunk = Chunk::new(
content,
Metadata::Presentation {
slide_number: slide.slide_number,
image: None,
}
);
if tx.blocking_send(Ok(chunk)).is_err() {
return;
}
if let Some(images) = slide.load_images_manually() {
for image in images.iter() {
let base64_data = &image.base64_content;
let total_length = base64_data.len();
let mut offset = 0;
let mut segment_index = 0;
while offset < total_length {
let end = min(offset + IMAGE_SEGMENT_SIZE_IN_CHARS, total_length);
let segment_content = &base64_data[offset..end];
let is_end = end == total_length;
let base64_image = Base64Image::new(
image.img_ref.id.clone(),
segment_content.to_string(),
segment_index,
is_end
);
let chunk = Chunk::new(
md_content,
String::new(),
Metadata::Presentation {
slide_number: slide.slide_number,
image: None,
image: Some(base64_image),
}
);
if tx.send(Ok(chunk)).await.is_err() {
break;
if tx.blocking_send(Ok(chunk)).is_err() {
return;
}
offset = end;
segment_index += 1;
}
if let Some(images) = slide.load_images_manually() {
for image in images.iter() {
let base64_data = &image.base64_content;
let total_length = base64_data.len();
let mut offset = 0;
let mut segment_index = 0;
while offset < total_length {
let end = min(offset + IMAGE_SEGMENT_SIZE_IN_CHARS, total_length);
let segment_content = &base64_data[offset..end];
let is_end = end == total_length;
let base64_image = Base64Image::new(
image.img_ref.id.clone(),
segment_content.to_string(),
segment_index,
is_end
);
let chunk = Chunk::new(
String::new(),
Metadata::Presentation {
slide_number: slide.slide_number,
image: Some(base64_image),
}
);
if tx.send(Ok(chunk)).await.is_err() {
break;
}
offset = end;
segment_index += 1;
}
}
}
},
Err(e) => {
let _ = tx.send(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)).await;
break;
}
}
}
});
tokio::spawn(async move {
if let Err(e) = worker.await {
let _ = worker_error_tx.send(Err(format!("Presentation parser task failed: {e}").into())).await;
}
});
Ok(Box::pin(ReceiverStream::new(rx)))
}
fn presentation_metadata_to_markdown(metadata: &PresentationMetadata) -> Option<String> {
let mut fields = Vec::new();
push_presentation_metadata_field(&mut fields, "Title", metadata.title.as_deref());
push_presentation_metadata_field(&mut fields, "Author", metadata.author.as_deref());
push_presentation_metadata_field(&mut fields, "Last Modified By", metadata.last_modified_by.as_deref());
push_presentation_metadata_field(&mut fields, "Subject", metadata.subject.as_deref());
push_presentation_metadata_field(&mut fields, "Description", metadata.description.as_deref());
if !metadata.keywords.is_empty() {
fields.push(format!(
"Keywords: {}",
sanitize_presentation_metadata_value(&metadata.keywords.join("; "))
));
}
push_presentation_metadata_field(&mut fields, "Created", metadata.created_at.as_deref());
push_presentation_metadata_field(&mut fields, "Modified", metadata.modified_at.as_deref());
if fields.is_empty() {
None
} else {
Some(format!(
"<!-- Presentation Metadata\n{}\n-->",
fields.join("\n")
))
}
}
fn push_presentation_metadata_field(fields: &mut Vec<String>, label: &str, value: Option<&str>) {
if let Some(value) = value {
fields.push(format!(
"{label}: {}",
sanitize_presentation_metadata_value(value)
));
}
}
fn sanitize_presentation_metadata_value(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace("--", "&#45;&#45;")
}
File diff suppressed because it is too large. Load diff
+385
View File
@@ -0,0 +1,385 @@
//! Local image preparation: decode a file, apply the size policy, and return it as a Data URL.
//!
//! This module is deliberately free of any feature-specific behavior so that every part of
//! AI Studio that needs an embeddable image can use it. The size policy is a single maximum edge
//! length; callers that want the original bytes pass `optimize = false`.
use std::io::Cursor;
use std::path::Path;
use axum::Json;
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat, ImageReader};
use serde::{Deserialize, Serialize};
/// The longest edge an optimized image may have. Larger images are scaled down proportionally.
const MAX_EDGE_PIXELS: u32 = 2_560;
/// The quality used when re-encoding JPEG images. Pinned so that repeated runs are byte-identical.
const JPEG_QUALITY: u8 = 85;
/// The request to prepare one local image file.
#[derive(Debug, Deserialize)]
pub struct PrepareImageRequest {
/// The absolute path of the image file to read.
path: String,
/// Whether the size policy and re-encoding are applied. When false, the original bytes are used.
optimize: bool,
}
/// The prepared image together with the dimensions the caller can lay out against.
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct PrepareImageResponse {
/// The complete `data:` URL, ready to embed.
data_url: String,
/// The MIME type matching the source format.
mime_type: String,
/// The width of the prepared image in pixels.
width: u32,
/// The height of the prepared image in pixels.
height: u32,
/// Whether the size policy actually scaled the image down.
was_resized: bool,
}
/// Decodes one supported image, applies the size policy, and returns a Data URL.
///
/// Decoding runs on a blocking worker because it is CPU-bound and would otherwise stall the
/// async runtime for large images.
pub async fn prepare_image(
Json(request): Json<PrepareImageRequest>,
) -> Result<Json<PrepareImageResponse>, (StatusCode, String)> {
tokio::task::spawn_blocking(move || prepare_image_sync(&request))
.await
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The image worker failed: {error}"),
)
})?
.map(Json)
}
/// Performs the blocking part of [`prepare_image`].
///
/// Only absolute paths to existing files are accepted, and the decoded format has to match the
/// file extension. Rejecting a mismatch keeps a file that merely claims to be an image from being
/// embedded under a MIME type derived from its name.
fn prepare_image_sync(
request: &PrepareImageRequest,
) -> Result<PrepareImageResponse, (StatusCode, String)> {
let path = Path::new(&request.path);
if !path.is_absolute() || !path.is_file() {
return Err((
StatusCode::BAD_REQUEST,
"The image path is not an accessible absolute file path.".to_string(),
));
}
let format = supported_format(path)?;
let reader = ImageReader::open(path)
.and_then(|reader| reader.with_guessed_format())
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be opened: {error}"),
)
})?;
if reader.format() != Some(format) {
return Err((
StatusCode::BAD_REQUEST,
"The image content does not match its file extension.".to_string(),
));
}
let decoded = reader.decode().map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be decoded: {error}"),
)
})?;
let original_width = decoded.width();
let original_height = decoded.height();
let should_resize = request.optimize && original_width.max(original_height) > MAX_EDGE_PIXELS;
let prepared = if should_resize {
resize_to_max_edge(decoded)
} else {
decoded
};
let width = prepared.width();
let height = prepared.height();
let bytes = if request.optimize {
encode(&prepared, format)?
} else {
std::fs::read(path).map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be read: {error}"),
)
})?
};
let mime_type = match format {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::WebP => "image/webp",
_ => unreachable!(),
}
.to_string();
Ok(PrepareImageResponse {
data_url: format!(
"data:{mime_type};base64,{}",
general_purpose::STANDARD.encode(bytes)
),
mime_type,
width,
height,
was_resized: should_resize,
})
}
/// Maps a file extension to the one image format AI Studio embeds.
///
/// The result is only the expected format; [`prepare_image_sync`] still verifies it against the
/// actual file content.
fn supported_format(path: &Path) -> Result<ImageFormat, (StatusCode, String)> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("jpg" | "jpeg") => Ok(ImageFormat::Jpeg),
Some("png") => Ok(ImageFormat::Png),
Some("webp") => Ok(ImageFormat::WebP),
_ => Err((
StatusCode::BAD_REQUEST,
"Images must be PNG, JPEG, or WebP files.".to_string(),
)),
}
}
/// Scales an image down so that its longest edge equals [`MAX_EDGE_PIXELS`].
///
/// The aspect ratio is preserved, and both edges stay at least one pixel wide.
fn resize_to_max_edge(image: DynamicImage) -> DynamicImage {
let width = image.width();
let height = image.height();
let scale = MAX_EDGE_PIXELS as f64 / width.max(height) as f64;
let target_width = (width as f64 * scale).round().max(1.0) as u32;
let target_height = (height as f64 * scale).round().max(1.0) as u32;
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
}
/// Encodes a prepared image back into its source format.
///
/// JPEG uses the pinned [`JPEG_QUALITY`] so that the same input always produces the same bytes,
/// which keeps artifact hashes stable across runs.
fn encode(image: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, (StatusCode, String)> {
let mut bytes = Vec::new();
match format {
ImageFormat::Jpeg => JpegEncoder::new_with_quality(&mut bytes, JPEG_QUALITY)
.encode_image(image)
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The JPEG image could not be encoded: {error}"),
)
})?,
ImageFormat::Png | ImageFormat::WebP => image
.write_to(&mut Cursor::new(&mut bytes), format)
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The image could not be encoded: {error}"),
)
})?,
_ => unreachable!(),
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn temporary_image_path(extension: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{unique}.{extension}"))
}
#[test]
fn rejects_unsupported_visual_asset_extension() {
let issue = supported_format(Path::new("/tmp/asset.gif")).unwrap_err();
assert_eq!(issue.0, StatusCode::BAD_REQUEST);
}
#[test]
fn keeps_supported_formats_stable() {
assert_eq!(
supported_format(Path::new("/tmp/asset.jpeg")).unwrap(),
ImageFormat::Jpeg
);
assert_eq!(
supported_format(Path::new("/tmp/asset.png")).unwrap(),
ImageFormat::Png
);
assert_eq!(
supported_format(Path::new("/tmp/asset.webp")).unwrap(),
ImageFormat::WebP
);
}
#[test]
fn serializes_response_in_snake_case_for_the_rust_service_contract() {
let response = PrepareImageResponse {
data_url: "data:image/jpeg;base64,/9j/".to_string(),
mime_type: "image/jpeg".to_string(),
width: 17,
height: 11,
was_resized: false,
};
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["data_url"], "data:image/jpeg;base64,/9j/");
assert_eq!(json["mime_type"], "image/jpeg");
assert_eq!(json["width"], 17);
assert_eq!(json["height"], 11);
assert_eq!(json["was_resized"], false);
assert!(json.get("dataUrl").is_none());
assert!(json.get("mimeType").is_none());
assert!(json.get("wasResized").is_none());
}
#[test]
fn disabled_optimization_preserves_original_bytes() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(4, 3)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let original = std::fs::read(&path).unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: false,
})
.unwrap();
let encoded = response.data_url.split_once(',').unwrap().1;
assert_eq!(general_purpose::STANDARD.decode(encoded).unwrap(), original);
assert!(!response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_resizes_only_images_over_the_maximum_edge() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(MAX_EDGE_PIXELS + 1, 1)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
assert_eq!(response.width, MAX_EDGE_PIXELS);
assert_eq!(response.height, 1);
assert!(response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_keeps_images_at_the_maximum_edge_unchanged() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(MAX_EDGE_PIXELS, 2)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
assert_eq!(response.width, MAX_EDGE_PIXELS);
assert_eq!(response.height, 2);
assert!(!response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimized_jpeg_uses_the_pinned_quality_encoder() {
let path = temporary_image_path("jpg");
let image = DynamicImage::new_rgb8(17, 11);
image.save_with_format(&path, ImageFormat::Jpeg).unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
let actual = general_purpose::STANDARD
.decode(response.data_url.split_once(',').unwrap().1)
.unwrap();
let mut expected = Vec::new();
JpegEncoder::new_with_quality(&mut expected, JPEG_QUALITY)
.encode_image(&image)
.unwrap();
assert_eq!(actual, expected);
assert_eq!(response.mime_type, "image/jpeg");
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_keeps_png_and_webp_formats_stable() {
for (extension, format, expected_mime) in [
("png", ImageFormat::Png, "image/png"),
("webp", ImageFormat::WebP, "image/webp"),
] {
let path = temporary_image_path(extension);
DynamicImage::new_rgba8(9, 7)
.save_with_format(&path, format)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
let bytes = general_purpose::STANDARD
.decode(response.data_url.split_once(',').unwrap().1)
.unwrap();
assert_eq!(image::guess_format(&bytes).unwrap(), format);
assert_eq!(response.mime_type, expected_mime);
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn rejects_file_contents_that_do_not_match_the_extension() {
let path = temporary_image_path("png");
std::fs::write(&path, b"not an image").unwrap();
let issue = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap_err();
assert_eq!(issue.0, StatusCode::BAD_REQUEST);
std::fs::remove_file(path).unwrap();
}
}
+3
View File
@@ -11,6 +11,8 @@ pub mod runtime_api;
pub mod runtime_certificate;
pub mod file_data;
pub mod metadata;
pub mod media;
pub mod image;
pub mod pdfium;
pub mod pandoc;
pub mod qdrant_edge_database;
@@ -20,3 +22,4 @@ pub mod stale_process_cleanup;
mod sidecar_types;
pub mod tokenizer;
mod file_actions;
pub mod global_shortcuts;
+243 -12
View File
@@ -2,7 +2,8 @@ use std::collections::BTreeMap;
use std::env::{current_dir, temp_dir};
use std::error::Error;
use std::fmt::Debug;
use std::path::{absolute, PathBuf};
use std::fs::{create_dir_all, OpenOptions};
use std::path::{absolute, Path, PathBuf};
use std::sync::OnceLock;
use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle};
use flexi_logger::writers::FileLogWriter;
@@ -11,7 +12,9 @@ use log::kv::{Key, Value, VisitSource};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::environment::is_dev;
use crate::environment::{is_dev, is_flatpak};
const FLATPAK_PERSISTENT_DATA_DIRECTORY: &str = "/var/data";
static LOGGER: OnceLock<RuntimeLoggerHandle> = OnceLock::new();
@@ -20,7 +23,7 @@ static LOG_STARTUP_PATH: OnceLock<String> = OnceLock::new();
static LOG_APP_PATH: OnceLock<String> = OnceLock::new();
/// Initialize the logging system.
pub fn init_logging() {
pub fn init_logging(bundle_identifier: &str) {
//
// Configure the LOGGER:
@@ -43,6 +46,7 @@ pub fn init_logging() {
log_config.push_str("tower_http=info, ");
log_config.push_str("rustls=info, ");
log_config.push_str("tokio_rustls=info, ");
log_config.push_str("symphonia_format_mkv=info, ");
log_config.push_str("reqwest=info");
// Configure the initial filename. On Unix systems, the file should start
@@ -53,14 +57,15 @@ pub fn init_logging() {
false => "AI Studio Events",
};
let (startup_log_directory, fallback_warning) = get_startup_log_path(bundle_identifier);
let log_path = FileSpec::default()
.directory(get_startup_log_path())
.directory(startup_log_directory)
.basename(log_basename)
.suppress_timestamp()
.suffix("log");
// Store the startup log path:
let _ = LOG_STARTUP_PATH.set(convert_log_path_to_string(&log_path));
store_startup_log_path(&LOG_STARTUP_PATH, &log_path);
let runtime_logger = Logger::try_with_str(log_config).expect("Cannot create logging")
.log_to_file(log_path)
@@ -77,6 +82,14 @@ pub fn init_logging() {
};
LOGGER.set(runtime_logger).expect("Cannot set LOGGER");
if let Some(fallback_warning) = fallback_warning {
log::warn!("{fallback_warning}");
}
}
fn store_startup_log_path(storage: &OnceLock<String>, log_path: &FileSpec) {
let _ = storage.set(convert_log_path_to_string(log_path));
}
fn convert_log_path_to_string(log_path: &FileSpec) -> String {
@@ -105,25 +118,123 @@ fn convert_log_path_to_string(log_path: &FileSpec) -> String {
}
}
fn get_startup_log_path(bundle_identifier: &str) -> (PathBuf, Option<String>) {
if is_flatpak() {
return select_flatpak_startup_log_path(
bundle_identifier,
dirs::data_local_dir(),
PathBuf::from(FLATPAK_PERSISTENT_DATA_DIRECTORY),
temp_dir(),
ensure_log_directory_is_writable,
).unwrap_or_else(|error| panic!("Cannot prepare a Flatpak startup log directory: {error}"));
}
(get_non_flatpak_startup_log_path(
home_directory(),
current_dir().ok(),
temp_dir(),
), None)
}
// Note: Rust plans to remove the deprecation flag for std::env::home_dir() in Rust 1.86.0.
#[allow(deprecated)]
fn get_startup_log_path() -> String {
match std::env::home_dir() {
fn home_directory() -> Option<PathBuf> {
std::env::home_dir()
}
fn get_non_flatpak_startup_log_path(
home_directory: Option<PathBuf>,
working_directory: Option<PathBuf>,
temporary_directory: PathBuf,
) -> PathBuf {
match home_directory {
// Case: We could determine the home directory:
Some(home_dir) => home_dir.to_str().unwrap().to_string(),
Some(home_directory) => home_directory,
// Case: We could not determine the home directory. Let's try to use the working directory:
None => match current_dir() {
None => match working_directory {
// Case: We could determine the working directory:
Ok(working_directory) => working_directory.to_str().unwrap().to_string(),
Some(working_directory) => working_directory,
// Case: We could not determine the working directory. Let's use the temporary directory:
Err(_) => temp_dir().to_str().unwrap().to_string(),
None => temporary_directory,
},
}
}
fn select_flatpak_startup_log_path<F>(
bundle_identifier: &str,
data_local_directory: Option<PathBuf>,
persistent_data_directory: PathBuf,
temporary_directory: PathBuf,
mut ensure_writable: F,
) -> Result<(PathBuf, Option<String>), String>
where
F: FnMut(&Path) -> Result<(), String>,
{
let standard_directory = data_local_directory.map(|directory| directory.join(bundle_identifier).join("data"));
let persistent_fallback = persistent_data_directory.join(bundle_identifier).join("data");
let temporary_fallback = temporary_directory.join(bundle_identifier).join("data");
let mut failures = Vec::new();
if let Some(standard_directory) = standard_directory {
match ensure_writable(&standard_directory) {
Ok(()) => return Ok((standard_directory, None)),
Err(error) => failures.push(format!("standard path failed: {error}")),
}
} else {
failures.push(String::from("standard path failed: dirs::data_local_dir() returned no path"));
}
match ensure_writable(&persistent_fallback) {
Ok(()) => {
let warning = format!(
"The standard Flatpak startup log directory was unavailable; using persistent fallback '{}'. {}",
persistent_fallback.display(),
failures.join("; "),
);
return Ok((persistent_fallback, Some(warning)));
},
Err(error) => failures.push(format!("persistent fallback failed: {error}")),
}
match ensure_writable(&temporary_fallback) {
Ok(()) => {
let warning = format!(
"The standard and persistent Flatpak startup log directories were unavailable; using temporary fallback '{}'. {}",
temporary_fallback.display(),
failures.join("; "),
);
Ok((temporary_fallback, Some(warning)))
},
Err(error) => {
failures.push(format!("temporary fallback failed: {error}"));
Err(failures.join("; "))
},
}
}
fn ensure_log_directory_is_writable(directory: &Path) -> Result<(), String> {
create_dir_all(directory).map_err(|error| format!("could not create '{}': {error}", directory.display()))?;
let log_file_path = directory.join(if cfg!(unix) {
".AI Studio Events.log"
} else {
"AI Studio Events.log"
});
OpenOptions::new()
.create(true)
.append(true)
.open(&log_file_path)
.map(|_| ())
.map_err(|error| format!("could not write '{}': {error}", log_file_path.display()))
}
/// Switch the logging system to a file-based output inside the given directory.
pub fn switch_to_file_logging(logger_path: PathBuf) -> Result<(), Box<dyn Error>>{
let log_path = FileSpec::default()
@@ -223,7 +334,7 @@ fn file_logger_format(
write_kv_pairs(w, record)?;
// Write the log message:
write!(w, "{}", &record.args())
write!(w, "{}", record.args())
}
pub async fn get_log_paths(_token: APIToken) -> Json<LogPathsResponse> {
@@ -315,4 +426,124 @@ pub struct LogEvent {
pub struct LogEventResponse {
success: bool,
issue: String,
}
#[cfg(test)]
mod tests {
use super::*;
const BUNDLE_IDENTIFIER: &str = "org.mindworkai.AIStudio";
#[test]
fn flatpak_standard_path_matches_tauri_local_data_path() {
let base_directory = PathBuf::from("/var/data");
let expected = base_directory.join(BUNDLE_IDENTIFIER).join("data");
let (selected, warning) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
Some(base_directory),
PathBuf::from("/persistent"),
PathBuf::from("/temporary"),
|_| Ok(()),
).unwrap();
assert_eq!(selected, expected);
assert!(warning.is_none());
}
#[test]
fn flatpak_uses_persistent_fallback_when_standard_path_is_unwritable() {
let standard = PathBuf::from("/standard").join(BUNDLE_IDENTIFIER).join("data");
let persistent = PathBuf::from("/var/data").join(BUNDLE_IDENTIFIER).join("data");
let (selected, warning) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
Some(PathBuf::from("/standard")),
PathBuf::from("/var/data"),
PathBuf::from("/temporary"),
|candidate| {
if candidate == standard {
Err(String::from("read-only"))
} else {
Ok(())
}
},
).unwrap();
assert_eq!(selected, persistent);
assert!(warning.unwrap().contains("persistent fallback"));
}
#[test]
fn flatpak_uses_temporary_fallback_when_persistent_path_is_unwritable() {
let temporary = PathBuf::from("/tmp").join(BUNDLE_IDENTIFIER).join("data");
let (selected, warning) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
None,
PathBuf::from("/var/data"),
PathBuf::from("/tmp"),
|candidate| {
if candidate == temporary {
Ok(())
} else {
Err(String::from("read-only"))
}
},
).unwrap();
assert_eq!(selected, temporary);
assert!(warning.unwrap().contains("temporary fallback"));
}
#[test]
fn non_flatpak_path_selection_keeps_existing_fallback_order() {
let home = PathBuf::from("/home/user");
let working = PathBuf::from("/working");
let temporary = PathBuf::from("/tmp");
assert_eq!(
get_non_flatpak_startup_log_path(Some(home.clone()), Some(working.clone()), temporary.clone()),
home,
);
assert_eq!(
get_non_flatpak_startup_log_path(None, Some(working.clone()), temporary.clone()),
working,
);
assert_eq!(
get_non_flatpak_startup_log_path(None, None, temporary.clone()),
temporary,
);
}
#[test]
fn startup_log_path_storage_uses_selected_fallback_path() {
let temporary = PathBuf::from("/tmp").join(BUNDLE_IDENTIFIER).join("data");
let (selected, _) = select_flatpak_startup_log_path(
BUNDLE_IDENTIFIER,
None,
PathBuf::from("/var/data"),
PathBuf::from("/tmp"),
|candidate| {
if candidate == temporary {
Ok(())
} else {
Err(String::from("unavailable"))
}
},
).unwrap();
let log_path = FileSpec::default()
.directory(selected)
.basename(".AI Studio Events")
.suppress_timestamp()
.suffix("log");
let storage = OnceLock::new();
store_startup_log_path(&storage, &log_path);
assert_eq!(
storage.get().unwrap(),
"/tmp/org.mindworkai.AIStudio/data/.AI Studio Events.log",
);
}
}
+19 -4
View File
@@ -12,11 +12,26 @@ use mindwork_ai_studio::metadata::MetaData;
use mindwork_ai_studio::runtime_api::start_runtime_api;
use mindwork_ai_studio::secret::init_secret_store;
#[tokio::main]
async fn main() {
// Keep `main` synchronous. Tauri owns the application's Tokio runtime, and Tauri itself as
// well as synchronous plugins may internally call `block_on` while they are initialized.
// In v26.7.3, `#[tokio::main]` caused the Linux single-instance plugin's synchronous D-Bus
// setup to enter `block_on` through zbus/tokio. Cargo feature unification made that path use
// Tokio, so startup panicked because a runtime was being started from inside another runtime.
//
// Run asynchronous background work with `tauri::async_runtime::spawn`. If startup must await
// asynchronous work, call `tauri::async_runtime::block_on` from this synchronous function
// before Tauri enters its event loop. If a real `#[tokio::main]` ever becomes unavoidable,
// first call `tauri::async_runtime::set(tokio::runtime::Handle::current())` before using any
// Tauri async function. Then audit every synchronously initialized Tauri plugin and transitive
// dependency for internal `block_on` calls. In particular, the Linux single-instance/D-Bus
// path must be made async, replaced, or moved to a non-conflicting backend. Such a runtime
// change requires explicit Linux startup tests; compiling successfully is not sufficient.
fn main() {
let metadata = MetaData::init_from_string(include_str!("../../metadata.txt"));
let tauri_context = tauri::generate_context!();
let bundle_identifier = tauri_context.config().identifier.clone();
init_logging();
init_logging(&bundle_identifier);
info!("Starting MindWork AI Studio:");
let working_directory = std::env::current_dir().unwrap();
@@ -46,5 +61,5 @@ async fn main() {
generate_runtime_certificate();
start_runtime_api();
start_tauri();
start_tauri(tauri_context);
}
+1971
View File
File diff suppressed because it is too large. Load diff
+26 -20
View File
@@ -1,42 +1,45 @@
use std::error::Error;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use once_cell::sync::{Lazy, OnceCell};
use pdfium_render::prelude::Pdfium;
use log::{error, warn};
use log::{error, info, warn};
pub static PDFIUM_LIB_PATH: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
static PDFIUM: Lazy<Mutex<Option<Pdfium>>> = Lazy::new(|| Mutex::new(None));
static PDFIUM: OnceCell<Pdfium> = OnceCell::new();
pub trait PdfiumInit {
fn ai_studio_init() -> Result<Pdfium, Box<dyn Error + Send + Sync>>;
fn ai_studio_init() -> Result<&'static Pdfium, Box<dyn Error + Send + Sync>>;
}
impl PdfiumInit for Pdfium {
/// Initializes the PDFium library for AI Studio.
fn ai_studio_init() -> Result<Pdfium, Box<dyn Error + Send + Sync>> {
let mut pdfium = PDFIUM.lock().unwrap();
if let Some(pdfium) = pdfium.as_ref() {
return Ok(pdfium.clone());
}
let loaded_pdfium = load_pdfium().map_err(|error| {
fn ai_studio_init() -> Result<&'static Pdfium, Box<dyn Error + Send + Sync>> {
PDFIUM.get_or_try_init(|| load_pdfium().map_err(|error| {
Box::new(std::io::Error::other(error)) as Box<dyn Error + Send + Sync>
})?;
*pdfium = Some(loaded_pdfium.clone());
Ok(loaded_pdfium)
}))
}
}
fn load_pdfium() -> Result<Pdfium, String> {
let lib_path = PDFIUM_LIB_PATH.lock().unwrap().clone();
if let Some(path) = lib_path.as_ref() {
return match Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) {
Ok(binding) => Ok(Pdfium::new(binding)),
let pdfium_library_path = Pdfium::pdfium_platform_library_name_at_path(path);
return match Pdfium::bind_to_library(&pdfium_library_path) {
Ok(binding) => {
info!("Loaded PDFium from '{path}'.", path = pdfium_library_path.to_string_lossy());
Ok(Pdfium::new(binding))
},
Err(library_error) => {
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Ok(binding) => {
info!(
"Loaded PDFium from the system library after failing to load '{path}'.",
path = pdfium_library_path.to_string_lossy(),
);
Ok(Pdfium::new(binding))
},
Err(system_error) => {
let error_message = format!(
"Failed to load PDFium from '{path}' and the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: library error: '{library_error}'; system error: '{system_error}'."
@@ -52,7 +55,10 @@ fn load_pdfium() -> Result<Pdfium, String> {
warn!("No custom PDFium library path set; trying to load PDFium from the system library.");
match Pdfium::bind_to_system_library() {
Ok(binding) => Ok(Pdfium::new(binding)),
Ok(binding) => {
info!("Loaded PDFium from the system library.");
Ok(Pdfium::new(binding))
},
Err(system_error) => {
let error_message = format!(
"Failed to load PDFium from the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: '{system_error}'."
@@ -62,4 +68,4 @@ fn load_pdfium() -> Result<Pdfium, String> {
Err(error_message)
}
}
}
}
+5 -6
View File
@@ -541,12 +541,11 @@ fn remove_obsolete_qdrant_sidecar_files<R: tauri::Runtime>(app_handle: &tauri::A
cfg_if::cfg_if! {
if #[cfg(any(target_os = "windows", target_os = "macos"))]{
if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent() {
if exe_dir.to_string_lossy().contains("MindWork AI Studio") {
paths.push(exe_dir.join("target").join("databases").join("qdrant"));
paths.push(exe_dir.join("qdrant.exe"));
paths.push(exe_dir.join("qdrant"));
}
if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent()
&& exe_dir.to_string_lossy().contains("MindWork AI Studio") {
paths.push(exe_dir.join("target").join("databases").join("qdrant"));
paths.push(exe_dir.join("qdrant.exe"));
paths.push(exe_dir.join("qdrant"));
}
}
}
+6 -1
View File
@@ -1,6 +1,6 @@
use log::info;
use once_cell::sync::Lazy;
use axum::routing::{get, post};
use axum::routing::{delete, get, post};
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
@@ -49,6 +49,7 @@ pub fn start_runtime_api() {
.route("/select/file", post(crate::file_actions::select_file))
.route("/select/files", post(crate::file_actions::select_files))
.route("/save/file", post(crate::file_actions::save_file))
.route("/open/path", post(crate::file_actions::open_path_in_file_manager))
.route("/secrets/get", post(crate::secret::get_secret))
.route("/secrets/store", post(crate::secret::store_secret))
.route("/secrets/delete", post(crate::secret::delete_secret))
@@ -62,6 +63,10 @@ pub fn start_runtime_api() {
.route("/system/enterprise/config/encryption_secret", get(crate::environment::read_enterprise_env_config_encryption_secret))
.route("/system/enterprise/configs", get(crate::environment::read_enterprise_configs))
.route("/retrieval/fs/extract", get(crate::file_data::extract_data))
.route("/media/jobs", post(crate::media::create_job))
.route("/media/jobs/{id}/events", get(crate::media::get_job_events))
.route("/media/jobs/{id}", delete(crate::media::cancel_job))
.route("/image/prepare", post(crate::image::prepare_image))
.route("/log/paths", get(crate::log::get_log_paths))
.route("/log/event", post(crate::log::log_event))
.route("/tokenizer/count", post(crate::tokenizer::token_count))
+96
View File
@@ -5,6 +5,46 @@ use serde::{Deserialize, Serialize};
use crate::api_token::APIToken;
use crate::encryption::{EncryptedText, ENCRYPTION};
/// A structured issue reported by the native credential store.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub enum SecretStoreIssueCode {
None,
SecretNotFound,
NoDefaultCollection,
CollectionLocked,
PromptDismissed,
ServiceUnavailable,
Unknown,
}
fn issue_code(error: &KeyringError) -> SecretStoreIssueCode {
if matches!(error, KeyringError::NoEntry) {
return SecretStoreIssueCode::SecretNotFound;
}
#[cfg(target_os = "linux")]
if let KeyringError::PlatformFailure(error) | KeyringError::NoStorageAccess(error) = error {
if let Some(error) = error.downcast_ref::<dbus_secret_service::Error>() {
return secret_service_issue_code(error);
}
}
SecretStoreIssueCode::Unknown
}
#[cfg(target_os = "linux")]
fn secret_service_issue_code(error: &dbus_secret_service::Error) -> SecretStoreIssueCode {
use dbus_secret_service::Error;
match error {
Error::NoResult => SecretStoreIssueCode::NoDefaultCollection,
Error::Locked => SecretStoreIssueCode::CollectionLocked,
Error::Prompt => SecretStoreIssueCode::PromptDismissed,
Error::Unavailable => SecretStoreIssueCode::ServiceUnavailable,
_ => SecretStoreIssueCode::Unknown,
}
}
/// Initializes the native credential store used by keyring-core.
pub fn init_secret_store() {
cfg_if::cfg_if! {
@@ -48,6 +88,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
return Json(StoreSecretResponse {
success: false,
issue: format!("Failed to decrypt the text: {e}"),
issue_code: SecretStoreIssueCode::Unknown,
})
},
};
@@ -60,6 +101,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
return Json(StoreSecretResponse {
success: false,
issue: e.to_string(),
issue_code: issue_code(&e),
});
},
};
@@ -70,6 +112,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
Json(StoreSecretResponse {
success: true,
issue: String::from(""),
issue_code: SecretStoreIssueCode::None,
})
},
@@ -78,6 +121,7 @@ pub async fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<
Json(StoreSecretResponse {
success: false,
issue: e.to_string(),
issue_code: issue_code(&e),
})
},
}
@@ -96,6 +140,7 @@ pub struct StoreSecret {
pub struct StoreSecretResponse {
success: bool,
issue: String,
issue_code: SecretStoreIssueCode,
}
/// Retrieves a secret from the secret store using the operating system's keyring.
@@ -113,6 +158,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to create secret entry for '{service}' and user '{user_name}': {e}"),
issue_code: issue_code(&e),
});
},
};
@@ -130,6 +176,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to encrypt the secret: {e}"),
issue_code: SecretStoreIssueCode::Unknown,
});
},
};
@@ -138,6 +185,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: true,
secret: encrypted_secret,
issue: String::from(""),
issue_code: SecretStoreIssueCode::None,
})
},
@@ -150,6 +198,7 @@ pub async fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<
success: false,
secret: EncryptedText::new(String::from("")),
issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"),
issue_code: issue_code(&e),
})
},
}
@@ -169,6 +218,7 @@ pub struct RequestedSecret {
success: bool,
secret: EncryptedText,
issue: String,
issue_code: SecretStoreIssueCode,
}
/// Deletes a secret from the secret store using the operating system's keyring.
@@ -183,6 +233,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: false,
was_entry_found: false,
issue: e.to_string(),
issue_code: issue_code(&e),
});
},
};
@@ -195,6 +246,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: true,
was_entry_found: true,
issue: String::from(""),
issue_code: SecretStoreIssueCode::None,
})
},
@@ -204,6 +256,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: true,
was_entry_found: false,
issue: String::from(""),
issue_code: SecretStoreIssueCode::SecretNotFound,
})
}
@@ -213,6 +266,7 @@ pub async fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Js
success: false,
was_entry_found: false,
issue: e.to_string(),
issue_code: issue_code(&e),
})
},
}
@@ -224,4 +278,46 @@ pub struct DeleteSecretResponse {
success: bool,
was_entry_found: bool,
issue: String,
issue_code: SecretStoreIssueCode,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_entry_is_reported_as_secret_not_found() {
assert_eq!(issue_code(&KeyringError::NoEntry), SecretStoreIssueCode::SecretNotFound);
}
#[test]
fn unrelated_keyring_error_uses_unknown_fallback() {
let error = KeyringError::Invalid("service".to_string(), "invalid".to_string());
assert_eq!(issue_code(&error), SecretStoreIssueCode::Unknown);
}
#[test]
fn issue_code_is_included_in_json() {
let response = StoreSecretResponse {
success: false,
issue: "technical details".to_string(),
issue_code: SecretStoreIssueCode::NoDefaultCollection,
};
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["issue_code"], "NoDefaultCollection");
assert_eq!(json["issue"], "technical details");
}
#[cfg(target_os = "linux")]
#[test]
fn secret_service_errors_are_mapped_to_issue_codes() {
use dbus_secret_service::Error;
assert_eq!(secret_service_issue_code(&Error::NoResult), SecretStoreIssueCode::NoDefaultCollection);
assert_eq!(secret_service_issue_code(&Error::Locked), SecretStoreIssueCode::CollectionLocked);
assert_eq!(secret_service_issue_code(&Error::Prompt), SecretStoreIssueCode::PromptDismissed);
assert_eq!(secret_service_issue_code(&Error::Unavailable), SecretStoreIssueCode::ServiceUnavailable);
assert_eq!(secret_service_issue_code(&Error::Parse), SecretStoreIssueCode::Unknown);
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
{
"productName": "MindWork AI Studio",
"mainBinaryName": "MindWork AI Studio",
"version": "26.6.2",
"version": "26.7.3",
"identifier": "com.github.mindwork-ai.ai-studio",
"build": {
@@ -27,7 +27,8 @@
"../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer"
],
"resources": [
"resources/libraries/*"
"resources/libraries/*",
"resources/notices/*"
],
"macOS": {
"exceptionDomain": "localhost"
+3
View File
@@ -0,0 +1,3 @@
{
"identifier": "org.mindworkai.AIStudio"
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
not a valid media container
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
WEBVTT
00:00.000 --> 00:00.100
fixture
Binary file not shown.
Binary file not shown.
Binary file not shown.