From 90988ebea4bacaab009bc09c5060f65d7f02ca57 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 19 Jul 2026 22:48:30 +0200 Subject: [PATCH] Fixed initial logging setup for Flatpak (#874) --- .../Assistants/I18N/allTexts.lua | 3 + .../Pages/Information.razor | 1 + .../plugin.lua | 3 + .../plugin.lua | 3 + app/MindWork AI Studio/Program.cs | 27 ++ .../Tools/Services/RustService.App.cs | 2 +- runtime/Cargo.lock | 1 + runtime/Cargo.toml | 1 + runtime/src/app_window.rs | 6 +- runtime/src/log.rs | 252 +++++++++++++++++- runtime/src/main.rs | 6 +- 11 files changed, 288 insertions(+), 17 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 085f6e43..d77b7dc1 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6994,6 +6994,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:" +-- dirs determines the platform-specific local application data directory. AI Studio uses it so the Flatpak startup log is written to the same application data directory that Tauri uses. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2325338322"] = "dirs determines the platform-specific local application data directory. AI Studio uses it so the Flatpak startup log is written to the same application data directory that Tauri uses." + -- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK." diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 45e47d0d..965017e9 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -304,6 +304,7 @@ + diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index a1c92af5..9a16a851 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -6996,6 +6996,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "Wir müssen Zufa -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-Plugin-ID:" +-- dirs determines the platform-specific local application data directory. AI Studio uses it so the Flatpak startup log is written to the same application data directory that Tauri uses. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2325338322"] = "dirs bestimmt das plattformspezifische lokale Anwendungsdatenverzeichnis. AI Studio verwendet es, damit das Flatpak-Startprotokoll in dasselbe Verzeichnis geschrieben wird, das auch Tauri verwendet." + -- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "Die Programmiersprache C# wird für die Umsetzung der Benutzeroberfläche und des Backends verwendet. Für die Entwicklung der Benutzeroberfläche mit C# kommt die Blazor-Technologie aus ASP.NET Core zum Einsatz. Alle diese Technologien sind im .NET SDK integriert." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 451125dc..610b5aaf 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -6996,6 +6996,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:" +-- dirs determines the platform-specific local application data directory. AI Studio uses it so the Flatpak startup log is written to the same application data directory that Tauri uses. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2325338322"] = "dirs determines the platform-specific local application data directory. AI Studio uses it so the Flatpak startup log is written to the same application data directory that Tauri uses." + -- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 29d4c562..3e775326 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -9,6 +9,7 @@ using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Logging.Console; @@ -111,6 +112,32 @@ internal sealed class Program options.FormatterName = TerminalLogger.FORMATTER_NAME; }).AddConsoleFormatter(); + if(runtimeInfo.LinuxPackageType == "flatpak") + { + try + { + var tauriDataDirectory = await rust.GetDataDirectory(); + if(string.IsNullOrWhiteSpace(tauriDataDirectory)) + throw new InvalidOperationException("Rust returned an empty Tauri data directory."); + + var dataProtectionKeysDirectory = Path.Combine(tauriDataDirectory, "data-protection-keys"); + Directory.CreateDirectory(dataProtectionKeysDirectory); + var writeTestPath = Path.Combine(dataProtectionKeysDirectory, $".write-test-{Guid.NewGuid():N}"); + using (new FileStream(writeTestPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose)) + { + } + + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysDirectory)) + .SetApplicationName("org.mindworkai.AIStudio"); + } + catch(Exception exception) + { + Console.WriteLine($"Error: Failed to configure Flatpak data-protection keys in the Tauri data directory: {exception.Message}"); + return; + } + } + builder.Services.AddMudExtensions(); builder.Services.AddMudServices(config => { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.App.cs b/app/MindWork AI Studio/Tools/Services/RustService.App.cs index 9fd0227f..974d9c19 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.App.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.App.cs @@ -113,7 +113,7 @@ public sealed partial class RustService var response = await this.http.GetAsync("/system/directories/data"); if (!response.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'"); + this.logger?.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'"); return string.Empty; } diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 606bbdb8..69cad297 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -4147,6 +4147,7 @@ dependencies = [ "cfg-if", "dbus-secret-service", "dbus-secret-service-keyring-store", + "dirs", "file-format", "flexi_logger", "futures", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index c5fc79dc..a6bc6e0a 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -24,6 +24,7 @@ tokio-stream = { version = "0.1.18", features = ["sync"] } futures = "0.3.32" async-stream = "0.3.6" flexi_logger = "0.31.9" +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"] } diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index 274d378f..3fa86892 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -12,7 +12,7 @@ use log::{debug, error, info, trace, warn}; use once_cell::sync::Lazy; use pdfium_render::prelude::Pdfium; use serde::{Deserialize, Serialize}; -use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context}; +use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent}; use tauri::path::PathResolver; use tauri::WebviewWindow; use tauri_plugin_updater::{UpdaterExt, Update}; @@ -52,7 +52,7 @@ static EVENT_BROADCAST: Lazy>>> = Lazy::ne static APPROVED_APP_URL: Lazy>> = Lazy::new(|| Mutex::new(None)); /// Starts the Tauri app. -pub fn start_tauri() { +pub fn start_tauri(tauri_context: tauri::Context) { info!("Starting Tauri app..."); // Create the event broadcast channel: @@ -179,7 +179,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: diff --git a/runtime/src/log.rs b/runtime/src/log.rs index 22741f0e..fc9a39e3 100644 --- a/runtime/src/log.rs +++ b/runtime/src/log.rs @@ -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 = OnceLock::new(); @@ -20,7 +23,7 @@ static LOG_STARTUP_PATH: OnceLock = OnceLock::new(); static LOG_APP_PATH: OnceLock = OnceLock::new(); /// Initialize the logging system. -pub fn init_logging() { +pub fn init_logging(bundle_identifier: &str) { // // Configure the LOGGER: @@ -54,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) @@ -78,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, log_path: &FileSpec) { + let _ = storage.set(convert_log_path_to_string(log_path)); } fn convert_log_path_to_string(log_path: &FileSpec) -> String { @@ -106,25 +118,123 @@ fn convert_log_path_to_string(log_path: &FileSpec) -> String { } } +fn get_startup_log_path(bundle_identifier: &str) -> (PathBuf, Option) { + 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 { + std::env::home_dir() +} + +fn get_non_flatpak_startup_log_path( + home_directory: Option, + working_directory: Option, + 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( + bundle_identifier: &str, + data_local_directory: Option, + persistent_data_directory: PathBuf, + temporary_directory: PathBuf, + mut ensure_writable: F, +) -> Result<(PathBuf, Option), 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>{ let log_path = FileSpec::default() @@ -316,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", + ); + } } \ No newline at end of file diff --git a/runtime/src/main.rs b/runtime/src/main.rs index b41b4c64..9461e97b 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -28,8 +28,10 @@ use mindwork_ai_studio::secret::init_secret_store; // 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(); @@ -59,5 +61,5 @@ fn main() { generate_runtime_certificate(); start_runtime_api(); - start_tauri(); + start_tauri(tauri_context); } \ No newline at end of file