using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace AIStudio.Tools; /// /// Writes files so that readers see either the old or the new content, never a part of it. /// /// /// The new content goes into a temporary file next to the target first, which then replaces the target by a rename. /// The temporary file has to live in the directory of the target: a rename across a file system boundary falls back /// to copying, which is exactly what this avoids, and the temporary directory of the operating system is such another /// file system under Flatpak. The temporary file is named after its target, followed by a GUID and ".tmp", so that /// a crash leaves a file behind which DeleteTemporaryFiles, DataSourceTranscriptStore, and MediaSidecarTranscripts /// recognize.

/// This works only where AI Studio may create files next to the target: in its data and configuration directories, /// or in a folder the user chose as a whole, e.g., the output folder of the Batch Processing assistant. It does not /// work for a single file the user chose in a save or an open dialog or dropped onto AI Studio: under Flatpak, the /// document portal shares only that one file, so nothing can be created next to it. Prepare such a file in the /// temporary directory and write it into the chosen file with FileMode.Create, as PresentationFile and /// PluginShareService do. Keep in mind, too, that the Flatpak sees the home directory read-only (--filesystem=home:ro). ///

/// The rename protects against a crash of AI Studio. Against a power failure, the content has to reach the disk /// before the rename, which flushToDisk asks for. That costs time on every write, especially on macOS and on SD cards, /// so only files which are hard to get back use it. ///
public static partial class AtomicFile { private const int BUFFER_SIZE = 65_536; /// /// Writes a file, unless the writer decides to discard what it wrote. /// /// The file to write. Its directory has to exist. /// Writes the new content into the stream and returns whether it replaces the target. /// The cancellation token. /// Whether an existing target is replaced; otherwise, an existing target fails the write with an IOException. /// Whether the content has to reach the disk before it replaces the target. /// Where to report a temporary file which cannot be removed. /// True when the new content replaced the target, false when the writer discarded it. public static async Task WriteOrDiscardAsync(string targetPath, Func> write, CancellationToken token, bool overwrite = true, bool flushToDisk = false, ILogger? logger = null) { var temporaryPath = $"{targetPath}.{Guid.NewGuid():N}.tmp"; try { bool keep; await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous)) { keep = await write(stream, token); if (keep && flushToDisk) await FlushToDiskAsync(stream, token); } if (!keep) { TryDelete(temporaryPath, logger); return false; } File.Move(temporaryPath, targetPath, overwrite); return true; } catch { // Deleting never throws, so the original error is the one which reaches the caller: TryDelete(temporaryPath, logger); throw; } } /// /// Writes a file. /// /// The file to write. Its directory has to exist. /// Writes the new content into the stream. /// The cancellation token. /// Whether an existing target is replaced; otherwise, an existing target fails the write with an IOException. /// Whether the content has to reach the disk before it replaces the target. /// Where to report a temporary file which cannot be removed. public static Task WriteAsync(string targetPath, Func write, CancellationToken token, bool overwrite = true, bool flushToDisk = false, ILogger? logger = null) => WriteOrDiscardAsync(targetPath, async (stream, writeToken) => { await write(stream, writeToken); return true; }, token, overwrite, flushToDisk, logger); /// /// Writes a text file. /// /// /// The file starts with the byte order mark of the encoding, if it has one, just like with File.WriteAllTextAsync: /// Encoding.UTF8 writes one, new UTF8Encoding(false) does not. /// /// The file to write. Its directory has to exist. /// The new content. /// The encoding of the file. /// The cancellation token. /// Whether an existing target is replaced; otherwise, an existing target fails the write with an IOException. /// Whether the content has to reach the disk before it replaces the target. /// Where to report a temporary file which cannot be removed. public static Task WriteTextAsync(string targetPath, string content, Encoding encoding, CancellationToken token, bool overwrite = true, bool flushToDisk = false, ILogger? logger = null) => WriteAsync(targetPath, (stream, writeToken) => WriteTextAsync(stream, content, encoding, writeToken), token, overwrite, flushToDisk, logger); /// /// Writes a text into the stream of a file, for a writer passed to WriteAsync or WriteOrDiscardAsync. /// /// /// The stream gets the byte order mark of the encoding, if it has one, just like a file written by File.WriteAllTextAsync. /// /// The stream of the file. /// The text. /// The encoding of the file. /// The cancellation token. public static async Task WriteTextAsync(Stream stream, string content, Encoding encoding, CancellationToken token) { await using var writer = new StreamWriter(stream, encoding, BUFFER_SIZE, leaveOpen: true); await writer.WriteAsync(content.AsMemory(), token); await writer.FlushAsync(token); } /// /// Writes a JSON file, as UTF-8 without a byte order mark. /// /// /// The value goes into the file piece by piece, so that the complete JSON is never in memory at once. The file /// holds the same bytes as when the value was serialized into a string and written by File.WriteAllTextAsync. /// /// The type of the value. /// The file to write. Its directory has to exist. /// The value to write. /// The JSON options. /// The cancellation token. /// Whether an existing target is replaced; otherwise, an existing target fails the write with an IOException. /// Whether the content has to reach the disk before it replaces the target. /// Where to report a temporary file which cannot be removed. public static Task WriteJsonAsync(string targetPath, T value, JsonSerializerOptions options, CancellationToken token, bool overwrite = true, bool flushToDisk = false, ILogger? logger = null) => WriteAsync(targetPath, (stream, writeToken) => JsonSerializer.SerializeAsync(stream, value, options, writeToken), token, overwrite, flushToDisk, logger); /// /// Deletes the temporary files which a crash left behind anywhere below a directory. /// /// /// Besides the temporary files of this class, it also deletes those which AI Studio named ".tmp-" followed by a /// GUID before, so that the leftovers of older versions disappear as well. Call it only for a directory which /// belongs to AI Studio alone. /// /// The directory to clean up. /// Where to report a temporary file which cannot be removed. public static void DeleteTemporaryFiles(string rootDirectory, ILogger? logger = null) { foreach (var path in Directory.EnumerateFiles(rootDirectory, "*.tmp*", SearchOption.AllDirectories)) { var fileName = Path.GetFileName(path); if (TemporaryFileName().IsMatch(fileName) || PreviousTemporaryFileName().IsMatch(fileName)) TryDelete(path, logger); } } /// /// Makes sure the content of a file reached the disk. /// /// /// FlushAsync hands the buffered content to the operating system only; just Flush(true) waits for the disk, and /// that only synchronously. It runs on the thread pool, so that it does not block the caller, e.g., the renderer. /// /// The stream of the file. /// The cancellation token. private static async Task FlushToDiskAsync(FileStream stream, CancellationToken token) { await stream.FlushAsync(token); await Task.Run(() => stream.Flush(flushToDisk: true), token); } /// /// Deletes a file, without failing. /// /// The file to delete; a file which does not exist is fine. /// Where to report a file which cannot be removed. private static void TryDelete(string path, ILogger? logger) { try { File.Delete(path); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { logger?.LogWarning(exception, "Could not remove the temporary file '{TemporaryPath}'.", path); } } [GeneratedRegex(@"\.[0-9a-f]{32}\.tmp$")] private static partial Regex TemporaryFileName(); [GeneratedRegex(@"\.tmp-[0-9a-f]{32}$")] private static partial Regex PreviousTemporaryFileName(); }