Files
AI-Studio/app/Build/Commands/Pdfium.cs
T

260 lines
12 KiB
C#
Raw Normal View History

using System.Formats.Tar;
using System.IO.Compression;
2026-11-07 13:14:58 +01:00
using Build.Tools;
using SharedTools;
namespace Build.Commands;
public static class Pdfium
{
2026-11-07 13:14:58 +01:00
private const int MAX_DOWNLOAD_ATTEMPTS = 3;
private static readonly TimeSpan CONNECT_TIMEOUT = TimeSpan.FromSeconds(30);
private static readonly TimeSpan RESPONSE_TIMEOUT = TimeSpan.FromSeconds(60);
private static readonly TimeSpan TRANSFER_TIMEOUT = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RETRY_DELAY = TimeSpan.FromSeconds(5);
//
// Without a handler of its own, HttpClient waits for a connection without any limit, so a single
// connection attempt which hangs uses up the whole timeout of the request. That happened twice while
// preparing v26.11.1, while curl fetched the same file within a second. The handler limits each
// connection attempt, the TLS handshake included, and each download attempt has its own time budget:
//
private static readonly HttpClient CLIENT = new(new SocketsHttpHandler { ConnectTimeout = CONNECT_TIMEOUT })
{
2026-11-07 13:14:58 +01:00
Timeout = Timeout.InfiniteTimeSpan
};
2026-11-07 13:14:58 +01:00
/// <summary>
/// Installs the Pdfium library of the given version into the resources of the Rust runtime.
/// </summary>
/// <remarks>
/// Every failure ends the build: without the library, AI Studio cannot read PDF files, and Tauri would fail
/// later with a far less clear message, because its resources include the folder of the library. The download
/// is skipped when the library of this version is already installed, which a version marker in the target
/// folder of the runtime records. The marker cannot sit next to the library, because Tauri ships everything
/// in that folder.
/// </remarks>
/// <param name="rid">The RID to install the library for.</param>
/// <param name="version">The Chromium revision of the Pdfium build, e.g., 7763.</param>
/// <param name="offline">True, when the build must not download anything and uses the library already present.</param>
public static async Task InstallAsync(RID rid, string version, bool offline)
{
Console.Write($"- Installing Pdfium {version} for {rid.UserFriendlyName} ...");
var library = GetLibraryPath(rid);
2026-11-07 13:14:58 +01:00
var pdfiumUrl = GetPdfiumDownloadUrl(rid, version);
if (string.IsNullOrWhiteSpace(library.Path) || string.IsNullOrWhiteSpace(pdfiumUrl))
{
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: There is no Pdfium library for {rid.UserFriendlyName}.");
throw new CommandExitedException(1);
}
var cwd = Environment.GetRustRuntimeDirectory();
var pdfiumLibTargetPath = Path.Join(cwd, "resources", "libraries", library.Filename);
2026-11-07 13:14:58 +01:00
var versionMarkerPath = Path.Join(cwd, "target", "pdfium.version");
var versionMarker = $"{version} {rid.MicrosoftRid}";
if (offline)
{
if (File.Exists(pdfiumLibTargetPath))
{
Console.WriteLine(" offline mode enabled and library already exists, skipping download");
return;
}
2026-11-07 13:14:58 +01:00
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: Offline mode is enabled, but '{pdfiumLibTargetPath}' does not exist. Build once without --offline to download it.");
throw new CommandExitedException(1);
}
2026-11-07 13:14:58 +01:00
if (File.Exists(pdfiumLibTargetPath) && await ReadVersionMarkerAsync(versionMarkerPath) == versionMarker)
{
2026-11-07 13:14:58 +01:00
Console.WriteLine(" already installed, skipping download");
return;
}
2026-11-07 13:14:58 +01:00
for (var attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++)
{
2026-11-07 13:14:58 +01:00
try
{
await DownloadAndDeployAsync(pdfiumUrl, library.Path.Replace('\\', '/'), pdfiumLibTargetPath, versionMarkerPath, versionMarker);
Console.WriteLine(" done.");
return;
}
catch (Exception exception) when (IsTransientFailure(exception))
{
Console.Write($" failed: {DescribeFailure(exception)}");
if (attempt < MAX_DOWNLOAD_ATTEMPTS)
{
Console.Write($" Retrying in {RETRY_DELAY.TotalSeconds:0} seconds ...");
await Task.Delay(RETRY_DELAY);
}
}
}
2026-11-07 13:14:58 +01:00
Console.WriteLine();
Console.WriteLine($"- Error: Could not download Pdfium {version} for {rid.UserFriendlyName} from {pdfiumUrl} in {MAX_DOWNLOAD_ATTEMPTS} attempts.");
if (File.Exists(pdfiumLibTargetPath))
Console.WriteLine(" To build with the library which is already present, use --offline.");
throw new CommandExitedException(1);
}
2026-11-07 13:14:58 +01:00
/// <summary>
/// Downloads the Pdfium archive once, extracts the library from it, and puts the library in place.
/// </summary>
/// <remarks>
/// A failure which the next attempt might not run into, such as a broken connection or a timeout, leaves this
/// method as an exception, which IsTransientFailure recognizes. A failure which every attempt would run into,
/// such as an unknown version, ends the build right here.
/// </remarks>
/// <param name="pdfiumUrl">The address of the Pdfium archive.</param>
/// <param name="pdfiumLibArchivePath">The path of the library inside the archive, with forward slashes.</param>
/// <param name="pdfiumLibTargetPath">Where to put the library.</param>
/// <param name="versionMarkerPath">Where to record which version the library has.</param>
/// <param name="versionMarker">The version and RID of the library.</param>
private static async Task DownloadAndDeployAsync(string pdfiumUrl, string pdfiumLibArchivePath, string pdfiumLibTargetPath, string versionMarkerPath, string versionMarker)
{
var pdfiumLibTmpTargetPath = $"{pdfiumLibTargetPath}.{Guid.NewGuid():N}.tmp";
var receivedResponse = false;
using var attemptTimeout = new CancellationTokenSource(RESPONSE_TIMEOUT);
try
{
2026-11-07 13:14:58 +01:00
//
// Download the file:
//
Console.Write(" downloading ...");
using var response = await CLIENT.GetAsync(pdfiumUrl, HttpCompletionOption.ResponseHeadersRead, attemptTimeout.Token);
receivedResponse = true;
if (!response.IsSuccessStatusCode)
{
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: The download of {pdfiumUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}. Check the Pdfium version in metadata.txt.");
throw new CommandExitedException(1);
}
//
// Extract the library from the downloaded file:
//
attemptTimeout.CancelAfter(TRANSFER_TIMEOUT);
Console.Write(" extracting ...");
Directory.CreateDirectory(Path.GetDirectoryName(pdfiumLibTargetPath)!);
var foundLibrary = false;
await using var downloadStream = await response.Content.ReadAsStreamAsync(attemptTimeout.Token);
await using var uncompressedStream = new GZipStream(downloadStream, CompressionMode.Decompress);
await using var tarReader = new TarReader(uncompressedStream);
2026-11-07 13:14:58 +01:00
while (await tarReader.GetNextEntryAsync(cancellationToken: attemptTimeout.Token) is { } entry)
{
if (!string.Equals(entry.Name.Replace('\\', '/'), pdfiumLibArchivePath, StringComparison.Ordinal))
continue;
if (entry.DataStream == null)
break;
await using var fileStream = File.Create(pdfiumLibTmpTargetPath);
2026-11-07 13:14:58 +01:00
await entry.DataStream.CopyToAsync(fileStream, attemptTimeout.Token);
foundLibrary = true;
break;
}
if (!foundLibrary)
{
2026-11-07 13:14:58 +01:00
Console.WriteLine(" failed.");
Console.WriteLine($"- Error: The Pdfium archive {pdfiumUrl} does not contain the library file '{pdfiumLibArchivePath}'.");
throw new CommandExitedException(1);
}
2026-11-07 13:14:58 +01:00
//
// Deploy the library. The marker goes first, so that it never describes a library
// which was only half replaced:
//
Console.Write(" deploying ...");
2026-11-07 13:14:58 +01:00
File.Delete(versionMarkerPath);
File.Move(pdfiumLibTmpTargetPath, pdfiumLibTargetPath, true);
2026-11-07 13:14:58 +01:00
Directory.CreateDirectory(Path.GetDirectoryName(versionMarkerPath)!);
await File.WriteAllTextAsync(versionMarkerPath, versionMarker, Environment.UTF8_NO_BOM);
}
catch (OperationCanceledException) when (attemptTimeout.IsCancellationRequested)
{
throw new TimeoutException(receivedResponse
? $"The download did not finish within {TRANSFER_TIMEOUT.TotalMinutes:0} minutes."
: $"There was no answer within {RESPONSE_TIMEOUT.TotalSeconds:0} seconds.");
}
catch (OperationCanceledException exception) when (exception.GetBaseException() is TimeoutException)
{
// The handler reports its connect timeout without the time it waited:
throw new TimeoutException($"No connection could be established within {CONNECT_TIMEOUT.TotalSeconds:0} seconds.");
}
finally
{
if (File.Exists(pdfiumLibTmpTargetPath))
File.Delete(pdfiumLibTmpTargetPath);
}
2026-11-07 13:14:58 +01:00
}
2026-11-07 13:14:58 +01:00
/// <summary>
/// Whether another attempt may succeed where a download attempt failed with the given exception.
/// </summary>
/// <param name="exception">The exception the attempt failed with.</param>
/// <returns>True, when the download is worth another attempt.</returns>
private static bool IsTransientFailure(Exception exception) => exception is HttpRequestException or OperationCanceledException or TimeoutException or IOException or InvalidDataException;
/// <summary>
/// Describes why a download attempt failed, in one line.
/// </summary>
/// <remarks>
/// The innermost exception names the actual cause, such as the socket error behind an HttpRequestException
/// or the timeout behind a canceled connection attempt, while the outer one often names the host.
/// </remarks>
/// <param name="exception">The exception the attempt failed with.</param>
/// <returns>The reason of the failure.</returns>
private static string DescribeFailure(Exception exception)
{
var innermost = exception.GetBaseException();
if (innermost == exception || exception is OperationCanceledException)
return innermost.Message;
return $"{exception.Message} {innermost.Message}";
}
private static async Task<string> ReadVersionMarkerAsync(string versionMarkerPath)
{
if (!File.Exists(versionMarkerPath))
return string.Empty;
return (await File.ReadAllTextAsync(versionMarkerPath, Encoding.UTF8)).Trim();
}
2026-11-07 13:14:58 +01:00
private static Library GetLibraryPath(RID rid) => rid switch
{
RID.LINUX_ARM64 or RID.LINUX_X64 => new(Path.Join("lib", "libpdfium.so"), "libpdfium.so"),
RID.OSX_ARM64 or RID.OSX_X64 => new(Path.Join("lib", "libpdfium.dylib"), "libpdfium.dylib"),
RID.WIN_ARM64 or RID.WIN_X64 => new(Path.Join("bin", "pdfium.dll"), "pdfium.dll"),
2026-11-07 13:14:58 +01:00
_ => new(string.Empty, string.Empty),
};
private static string GetPdfiumDownloadUrl(RID rid, string version)
{
var baseUrl = $"https://github.com/bblanchon/pdfium-binaries/releases/download/chromium%2F{version}/pdfium-";
return rid switch
{
RID.LINUX_ARM64 => $"{baseUrl}linux-arm64.tgz",
RID.LINUX_X64 => $"{baseUrl}linux-x64.tgz",
2026-11-07 13:14:58 +01:00
RID.OSX_ARM64 => $"{baseUrl}mac-arm64.tgz",
RID.OSX_X64 => $"{baseUrl}mac-x64.tgz",
2026-11-07 13:14:58 +01:00
RID.WIN_ARM64 => $"{baseUrl}win-arm64.tgz",
RID.WIN_X64 => $"{baseUrl}win-x64.tgz",
2026-11-07 13:14:58 +01:00
_ => string.Empty,
};
}
}