Fixed the Flatpak not starting and improved logging of .NET server failures (#1014)
Some checks are pending
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-09-28 18:37:58 +02:00 committed by GitHub
parent d61c5e67bd
commit cb99e9a668
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 99 additions and 10 deletions

View File

@ -9,4 +9,4 @@
c475f5dfd4f, release
osx-arm64
148.0.7763.0
0.8.0
0.8.0

View File

@ -5,8 +5,8 @@ use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use log::{error, info, warn};
use once_cell::sync::Lazy;
use tauri::Url;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri::{Manager, Url};
use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload};
use tauri_plugin_shell::ShellExt;
use crate::api_token::APIToken;
use crate::runtime_api_token::API_TOKEN;
@ -39,8 +39,14 @@ static DOTNET_INITIALIZED: Lazy<Mutex<bool>> = Lazy::new(|| Mutex::new(false));
pub const PID_FILE_NAME: &str = "mindwork_ai_studio.pid";
const SIDECAR_TYPE:SidecarType = SidecarType::Dotnet;
/// Removes ANSI escape sequences and non-printable control chars from stdout lines.
fn sanitize_stdout_line(line: &str) -> String {
/// Tells the .NET host where to extract the native libraries bundled into the server.
const DOTNET_ENV_BUNDLE_EXTRACT_BASE_DIR: &str = "DOTNET_BUNDLE_EXTRACT_BASE_DIR";
/// The directory below the app's cache directory where the .NET host extracts them.
const DOTNET_BUNDLE_EXTRACTION_DIRECTORY_NAME: &str = "dotnet";
/// Removes ANSI escape sequences and non-printable control chars from stdout and stderr lines.
fn sanitize_output_line(line: &str) -> String {
let mut sanitized = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
@ -92,6 +98,15 @@ fn sanitize_stdout_line(line: &str) -> String {
sanitized
}
/// Describes how a process ended, for the log.
fn describe_termination(payload: &TerminatedPayload) -> String {
match (payload.code, payload.signal) {
(Some(code), _) => format!("exit code {code}"),
(None, Some(signal)) => format!("signal {signal}"),
(None, None) => String::from("neither an exit code nor a signal"),
}
}
/// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get
/// the port where the .NET server should listen to.
pub async fn dotnet_port(_token: APIToken) -> String {
@ -113,6 +128,45 @@ fn external_http_custom_root_certificate_policy_environment() -> Vec<(String, St
]
}
/// Returns the directory where the .NET host extracts the native libraries of the server.
///
/// The server is a single-file bundle, and the native libraries inside it -- SQLite above all --
/// have to be written to disk before the .NET host can load them. That happens before any of our
/// own .NET code runs. Left to itself, the host picks `$HOME/.net` or `%TEMP%\.net` and exits at
/// once when that location is not writable. The Flatpak sandbox mounts the home directory
/// read-only, so the server never started there. Our own cache directory is writable wherever
/// the app runs, which is why we choose the location on every platform instead of trusting the
/// default.
///
/// Returns `None` when the directory is not available. The host then falls back to its default,
/// and should that fail as well, its message shows up in our log as stderr of the .NET server.
fn dotnet_bundle_extraction_directory<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Option<String> {
let cache_directory = match app_handle.path().app_cache_dir() {
Ok(path) => path,
Err(e) => {
error!(Source = "Bootloader .NET"; "Failed to resolve the app cache directory for extracting the native libraries of the .NET server: {e}");
return None;
}
};
let extraction_directory = cache_directory.join(DOTNET_BUNDLE_EXTRACTION_DIRECTORY_NAME);
if let Err(e) = std::fs::create_dir_all(&extraction_directory) {
error!(Source = "Bootloader .NET"; "Failed to create the directory '{}' for extracting the native libraries of the .NET server: {e}", extraction_directory.display());
return None;
}
match extraction_directory.to_str() {
Some(path) => {
info!(Source = "Bootloader .NET"; "The .NET server extracts its native libraries to '{path}'.");
Some(path.to_string())
}
None => {
error!(Source = "Bootloader .NET"; "The directory '{}' for extracting the native libraries of the .NET server is not valid UTF-8.", extraction_directory.display());
None
}
}
}
/// Creates the startup environment file for the .NET server in the development
/// environment. The file is created in the root directory of the repository.
/// Creating that env file on a production environment would be a security
@ -164,6 +218,9 @@ pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
]);
dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment());
if let Some(extraction_directory) = dotnet_bundle_extraction_directory(&app_handle) {
dotnet_server_environment.insert(String::from(DOTNET_ENV_BUNDLE_EXTRACT_BASE_DIR), extraction_directory);
}
info!("Try to start the .NET server...");
let server_spawn_clone = DOTNET_SERVER.clone();
@ -185,13 +242,45 @@ pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
// Log the output of the .NET server:
// NOTE: Log events are sent via structured HTTP API calls.
// This loop serves for fundamental output (e.g., startup errors).
//
// The .NET host writes its own failures to stderr, before any of our .NET code runs.
// Without stderr and the termination, a server that cannot even start leaves no trace.
while let Some(event) = rx.recv().await {
if let CommandEvent::Stdout(line) = event {
let line_utf8 = String::from_utf8_lossy(&line).to_string();
let line = sanitize_stdout_line(line_utf8.trim_end());
if !line.trim().is_empty() {
info!(Source = ".NET Server (stdout)"; "{line}");
match event {
CommandEvent::Stdout(line) => {
let line_utf8 = String::from_utf8_lossy(&line).to_string();
let line = sanitize_output_line(line_utf8.trim_end());
if !line.trim().is_empty() {
info!(Source = ".NET Server (stdout)"; "{line}");
}
}
CommandEvent::Stderr(line) => {
let line_utf8 = String::from_utf8_lossy(&line).to_string();
let line = sanitize_output_line(line_utf8.trim_end());
if !line.trim().is_empty() {
error!(Source = ".NET Server (stderr)"; "{line}");
}
}
CommandEvent::Error(e) => {
error!(Source = "Bootloader .NET"; "Failed to read the output of the .NET server: {e}");
}
CommandEvent::Terminated(payload) => {
//
// stop_dotnet_server() takes the child out before killing it. When the child is
// still here, nobody asked the server to stop.
//
let termination = describe_termination(&payload);
if server_spawn_clone.lock().unwrap().is_some() {
error!(Source = "Bootloader .NET"; "The .NET server process terminated unexpectedly with {termination}.");
} else {
info!(Source = "Bootloader .NET"; "The .NET server process terminated with {termination}.");
}
}
_ => (),
}
}
});