Fixed & improved pandoc handling (#762)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (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-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (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 / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (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 authored and GitHub committed 2026-05-16 18:27:16 +02:00
1 parent 8f0effd25b
commit 91cfe8dcd0
8 files changed
+384 -70

No files matched your search

+189 -29
View File
@@ -35,12 +35,13 @@ public static partial class Pandoc
private static bool HAS_LOGGED_AVAILABILITY_CHECK_ONCE;
private static readonly HttpClient WEB_CLIENT = new();
private static readonly SemaphoreSlim INSTALLATION_LOCK = new(1, 1);
/// <summary>
/// Prepares a Pandoc process by using the Pandoc process builder.
/// </summary>
/// <returns>The Pandoc process builder with default settings.</returns>
public static PandocProcessBuilder PreparePandocProcess() => PandocProcessBuilder.Create();
private static PandocProcessBuilder PreparePandocProcess() => PandocProcessBuilder.Create();
/// <summary>
/// Checks if pandoc is available on the system and can be started as a process or is present in AI Studio's data dir.
@@ -145,12 +146,12 @@ public static partial class Pandoc
catch (Exception e)
{
if (showMessages)
await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("It seems that Pandoc is not installed.")));
await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("Pandoc doesn't seem to be installed.")));
if(shouldLog)
LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH.");
return new(false, TB("It seems that Pandoc is not installed."), false, string.Empty, false);
return new(false, TB("Pandoc doesn't seem to be installed."), false, string.Empty, false);
}
finally
{
@@ -165,76 +166,230 @@ public static partial class Pandoc
/// <returns>None</returns>
public static async Task InstallAsync(RustService rustService)
{
await INSTALLATION_LOCK.WaitAsync();
var latestVersion = await FetchLatestVersionAsync();
var installDir = await GetPandocDataFolder(rustService);
ClearFolder(installDir);
var installParentDir = Path.GetDirectoryName(installDir) ?? Path.GetTempPath();
var stagingDir = Path.Combine(installParentDir, $"pandoc-install-{Guid.NewGuid():N}");
var pandocTempDownloadFile = Path.GetTempFileName();
LOG.LogInformation("Trying to install Pandoc v{0} to '{1}'...", latestVersion, installDir);
try
{
if (!Directory.Exists(installDir))
Directory.CreateDirectory(installDir);
// Create a temporary file to download the archive to:
var pandocTempDownloadFile = Path.GetTempFileName();
if (!Directory.Exists(installParentDir))
Directory.CreateDirectory(installParentDir);
//
// Download the latest Pandoc archive from GitHub:
//
var uri = await GenerateArchiveUriAsync();
var response = await WEB_CLIENT.GetAsync(uri);
var uri = GenerateArchiveUri(latestVersion);
if (string.IsNullOrWhiteSpace(uri))
{
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive type is unknown.")));
LOG.LogError("Pandoc was not installed, no archive is available for architecture '{Architecture}'.", CPU_ARCHITECTURE.ToUserFriendlyName());
return;
}
using var response = await WEB_CLIENT.GetAsync(uri);
if (!response.IsSuccessStatusCode)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("Pandoc was not installed successfully, because the archive was not found.")));
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive was not found.")));
LOG.LogError("Pandoc was not installed successfully, because the archive was not found (status code {0}): url='{1}', message='{2}'", response.StatusCode, uri, response.RequestMessage);
return;
}
// Download the archive to the temporary file:
await using var tempFileStream = File.Create(pandocTempDownloadFile);
await response.Content.CopyToAsync(tempFileStream);
await using (var tempFileStream = File.Create(pandocTempDownloadFile))
{
await response.Content.CopyToAsync(tempFileStream);
await tempFileStream.FlushAsync();
}
Directory.CreateDirectory(stagingDir);
if (uri.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(pandocTempDownloadFile, installDir);
await RunWithRetriesAsync(
() =>
{
ZipFile.ExtractToDirectory(pandocTempDownloadFile, stagingDir, true);
return Task.CompletedTask;
},
"extracting the Pandoc ZIP archive");
}
else if (uri.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
await using var tgzStream = File.Open(pandocTempDownloadFile, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var uncompressedStream = new GZipStream(tgzStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(uncompressedStream, installDir, true);
await RunWithRetriesAsync(
async () =>
{
await using var tgzStream = File.Open(pandocTempDownloadFile, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var uncompressedStream = new GZipStream(tgzStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(uncompressedStream, stagingDir, true);
},
"extracting the Pandoc TAR archive");
}
else
{
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("Pandoc was not installed successfully, because the archive type is unknown.")));
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive type is unknown.")));
LOG.LogError("Pandoc was not installed, the archive is unknown: url='{0}'", uri);
return;
}
File.Delete(pandocTempDownloadFile);
var stagedPandocExecutable = FindExecutableInDirectory(stagingDir, PandocProcessBuilder.PandocExecutableName);
if (string.IsNullOrWhiteSpace(stagedPandocExecutable))
{
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the executable was not found in the archive.")));
LOG.LogError("Pandoc was not installed, the executable was not found in the extracted archive: '{StagingDir}'.", stagingDir);
return;
}
LOG.LogInformation("Found Pandoc executable in downloaded archive: '{Executable}'.", stagedPandocExecutable);
await ReplaceInstallationDirectoryAsync(stagingDir, installDir);
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, string.Format(TB("Pandoc v{0} was installed successfully."), latestVersion)));
LOG.LogInformation("Pandoc v{0} was installed successfully.", latestVersion);
}
catch (Exception ex)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc.")));
LOG.LogError(ex, "An error occurred while installing Pandoc.");
}
finally
{
TryDeleteFile(pandocTempDownloadFile);
if (Directory.Exists(stagingDir))
await TryDeleteFolderAsync(stagingDir);
INSTALLATION_LOCK.Release();
}
}
private static void ClearFolder(string path)
private static async Task ReplaceInstallationDirectoryAsync(string stagingDir, string installDir)
{
if (!Directory.Exists(path))
return;
var backupDir = $"{installDir}.backup-{Guid.NewGuid():N}";
var hasBackup = false;
var stagingWasMoved = false;
try
{
Directory.Delete(path, true);
if (Directory.Exists(installDir))
{
await MoveDirectoryWithRetriesAsync(installDir, backupDir, "moving the previous Pandoc installation to backup");
hasBackup = true;
}
await MoveDirectoryWithRetriesAsync(stagingDir, installDir, "moving the new Pandoc installation into place");
stagingWasMoved = true;
}
catch (Exception ex)
{
LOG.LogError(ex, "Error clearing pandoc installation directory.");
if (hasBackup && !stagingWasMoved && !Directory.Exists(installDir) && Directory.Exists(backupDir))
{
try
{
await MoveDirectoryWithRetriesAsync(backupDir, installDir, "restoring the previous Pandoc installation");
hasBackup = false;
}
catch (Exception rollbackEx)
{
LOG.LogError(rollbackEx, "Error restoring previous Pandoc installation directory. Keeping backup directory at: '{BackupDir}'.", backupDir);
}
}
LOG.LogError(ex, "Error replacing pandoc installation directory.");
throw;
}
finally
{
if (hasBackup && stagingWasMoved && Directory.Exists(backupDir))
await TryDeleteFolderAsync(backupDir);
}
}
private static string FindExecutableInDirectory(string rootDirectory, string executableName)
{
if (!Directory.Exists(rootDirectory))
return string.Empty;
var rootExecutablePath = Path.Combine(rootDirectory, executableName);
if (File.Exists(rootExecutablePath))
return rootExecutablePath;
foreach (var subdirectory in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories))
{
var pandocPath = Path.Combine(subdirectory, executableName);
if (File.Exists(pandocPath))
return pandocPath;
}
return string.Empty;
}
private static async Task MoveDirectoryWithRetriesAsync(string sourceDir, string destinationDir, string operationName)
{
await RunWithRetriesAsync(
() =>
{
Directory.Move(sourceDir, destinationDir);
return Task.CompletedTask;
},
operationName,
maxAttempts: 8);
}
private static async Task RunWithRetriesAsync(Func<Task> operation, string operationName, int maxAttempts = 4)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
await operation();
return;
}
catch (Exception ex) when (attempt < maxAttempts && ex is IOException or UnauthorizedAccessException)
{
LOG.LogWarning(ex, "Error while {OperationName}; retrying attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, maxAttempts);
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt));
}
}
}
private static void TryDeleteFile(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return;
try
{
File.Delete(path);
}
catch (Exception ex)
{
LOG.LogWarning(ex, "Was not able to delete temporary Pandoc archive: '{Path}'.", path);
}
}
private static async Task TryDeleteFolderAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
return;
try
{
await RunWithRetriesAsync(
() =>
{
Directory.Delete(path, true);
return Task.CompletedTask;
},
$"deleting temporary Pandoc directory '{path}'",
maxAttempts: 3);
}
catch (Exception ex)
{
LOG.LogWarning(ex, "Was not able to delete temporary Pandoc directory: '{Path}'.", path);
}
}
@@ -248,7 +403,7 @@ public static partial class Pandoc
if (!response.IsSuccessStatusCode)
{
LOG.LogError("Code {StatusCode}: Could not fetch Pandoc's latest page: {Response}", response.StatusCode, response.RequestMessage);
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("The latest Pandoc version was not found, installing version {0} instead."), FALLBACK_VERSION.ToString())));
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("AI Studio couldn't find the latest Pandoc version and will install version {0} instead."), FALLBACK_VERSION.ToString())));
return FALLBACK_VERSION.ToString();
}
@@ -257,7 +412,7 @@ public static partial class Pandoc
if (!versionMatch.Success)
{
LOG.LogError("The latest version regex returned nothing: {0}", versionMatch.Groups.ToString());
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("The latest Pandoc version was not found, installing version {0} instead."), FALLBACK_VERSION.ToString())));
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("AI Studio couldn't find the latest Pandoc version and will install version {0} instead."), FALLBACK_VERSION.ToString())));
return FALLBACK_VERSION.ToString();
}
@@ -272,6 +427,11 @@ public static partial class Pandoc
public static async Task<string> GenerateArchiveUriAsync()
{
var version = await FetchLatestVersionAsync();
return GenerateArchiveUri(version);
}
private static string GenerateArchiveUri(string version)
{
var baseUri = $"{DOWNLOAD_URL}/{version}/pandoc-{version}-";
return CPU_ARCHITECTURE switch
{
@@ -220,6 +220,17 @@ public sealed class PandocProcessBuilder
}
}
foreach (var candidate in SystemPandocExecutableCandidates(PandocExecutableName))
{
if (!File.Exists(candidate))
continue;
if (shouldLog)
LOGGER.LogInformation("Found system Pandoc installation at: '{Path}'.", candidate);
return new(candidate, false);
}
//
// When no local installation was found, we assume that the pandoc executable is in the system PATH:
//
@@ -238,4 +249,59 @@ public sealed class PandocProcessBuilder
/// Reads the os platform to determine the used executable name.
/// </summary>
public static string PandocExecutableName => CPU_ARCHITECTURE is RID.WIN_ARM64 or RID.WIN_X64 ? "pandoc.exe" : "pandoc";
private static IEnumerable<string> SystemPandocExecutableCandidates(string executableName)
{
var candidates = new List<string>();
switch (CPU_ARCHITECTURE)
{
case RID.WIN_X64 or RID.WIN_ARM64:
AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Pandoc", executableName);
AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Pandoc", executableName);
AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Pandoc", executableName);
break;
case RID.OSX_X64 or RID.OSX_ARM64:
AddCandidate(candidates, "/opt/homebrew/bin", executableName);
AddCandidate(candidates, "/usr/local/bin", executableName);
AddCandidate(candidates, "/usr/bin", executableName);
break;
case RID.LINUX_X64 or RID.LINUX_ARM64:
AddCandidate(candidates, "/usr/local/bin", executableName);
AddCandidate(candidates, "/usr/bin", executableName);
AddCandidate(candidates, "/snap/bin", executableName);
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
AddCandidate(candidates, homeDirectory, ".local", "bin", executableName);
break;
}
foreach (var pathDirectory in GetPathDirectories())
AddCandidate(candidates, pathDirectory, executableName);
var comparer = CPU_ARCHITECTURE is RID.WIN_X64 or RID.WIN_ARM64
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
return candidates.Distinct(comparer);
}
private static IEnumerable<string> GetPathDirectories()
{
var pathValue = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(pathValue))
yield break;
foreach (var pathDirectory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
yield return pathDirectory;
}
private static void AddCandidate(List<string> candidates, params string[] pathParts)
{
if (pathParts.Any(string.IsNullOrWhiteSpace))
return;
candidates.Add(Path.Combine(pathParts));
}
}