Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/EventLogExpert.Runtime/Common/Files/AtomicFileWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Common.Files;

/// <summary>
/// Writes a file atomically: the caller's content is streamed into a fixed-name sibling temp file in the
/// destination's own directory, then committed with an atomic same-volume
/// <see cref="File.Move(string, string, bool)" /> (overwrite). Because the temp always lives on the same volume as the
/// destination, the commit is an atomic replace-by-rename (MoveFileEx MOVEFILE_REPLACE_EXISTING): the destination
/// always ends up as either its prior content or the fully written new content -- never missing or partial -- even
/// when the commit itself fails. A cancellation or a failed write leaves the destination untouched and always cleans
/// up the temp (the temp is deleted on every path that does not commit). This is pure <see cref="System.IO" /> with no
/// WinRT dependency, so it works unchanged under elevated (packaged or unpackaged) processes.
/// </summary>
public static class AtomicFileWriter
{
public static async Task WriteAsync(
string destinationPath,
Func<Stream, CancellationToken, Task> writeContent,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(destinationPath);
ArgumentNullException.ThrowIfNull(writeContent);

cancellationToken.ThrowIfCancellationRequested();

var directory = Path.GetDirectoryName(destinationPath);

if (string.IsNullOrEmpty(directory))
{
throw new ArgumentException(
"The destination path must include a directory component.", nameof(destinationPath));
}

// Fixed-length sibling name (independent of the destination filename) so that a near-maximum-length
// destination name cannot push the temp path past the file-system's path-component limit.
var tempPath = Path.Combine(directory, $".eventlogexpert-{Guid.NewGuid():N}.tmp");
var committed = false;

try
{
// Explicit braces: the temp stream MUST be disposed (its handle released) BEFORE the commit, otherwise the
// File.Move replace fails with a sharing violation. Do not collapse to `await using var`.
await using (var tempStream = new FileStream(tempPath, new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
// Asynchronous: writeContent streams via WriteAsync, so use true overlapped I/O rather than sync I/O
// dispatched to the thread pool (matters for large exports; mirrors DebugLogFileReader's convention).
Options = FileOptions.Asynchronous,
}))
{
await writeContent(tempStream, cancellationToken);
}

// Final cancellation gate: the last point at which a Cancel can abort the save. After this the commit is
// an uncancellable rename.
cancellationToken.ThrowIfCancellationRequested();

Commit(tempPath, destinationPath);
committed = true;
}
finally
{
if (!committed) { TryDelete(tempPath); }
}
}

private static void Commit(string tempPath, string destinationPath) =>
// Atomic same-volume replace-by-rename. MoveFileEx(MOVEFILE_REPLACE_EXISTING) either fully succeeds or leaves
// both files intact, so a failed commit can never destroy the existing destination. We deliberately do NOT use
// File.Replace: its underlying ReplaceFile has documented partial-failure states (e.g.
// ERROR_UNABLE_TO_MOVE_REPLACEMENT_2) that can delete the destination while orphaning the replacement under the
// temp name -- a data-loss hazard in a save path. The committed file takes the destination directory's inherited
// ACL, the correct outcome for freshly written export content (the comdlg32 Save dialog's OFN_OVERWRITEPROMPT
// already obtained the user's consent to overwrite an existing file).
File.Move(tempPath, destinationPath, overwrite: true);

private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Best-effort: the temp is never the saved output, so a failed cleanup must not mask the real result.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

namespace EventLogExpert.Adapters.FilePicker;

/// <summary>MAUI adapter that delegates to <see cref="WinUiFolderPicker.PickFolderAsync"/>.</summary>
internal sealed class MauiFolderPickerService : IFolderPickerService
{
public Task<string?> PickFolderAsync() => WinUiFolderPicker.PickFolderAsync();
public Task<string?> PickFolderAsync() =>
MainThread.InvokeOnMainThreadAsync(() => Win32FileDialogService.PickFolderAsync("Select Folder"));
}
182 changes: 16 additions & 166 deletions src/EventLogExpert/Adapters/FileSave/MauiFileSaveService.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

using EventLogExpert.Platforms.Windows;
using EventLogExpert.Runtime.Common.Files;
using System.Runtime.InteropServices;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.Storage.Provider;

namespace EventLogExpert.Adapters.FileSave;

public sealed class MauiFileSaveService : IFileSaveService
public sealed class MauiFileSaveService(IFilePickerService filePickerService) : IFileSaveService
{
private readonly IFilePickerService _filePickerService = filePickerService;

public async Task<string?> SaveStreamingAsync(
string suggestedFileName,
IReadOnlyDictionary<string, IReadOnlyList<string>> fileTypes,
Expand All @@ -24,177 +21,30 @@ public sealed class MauiFileSaveService : IFileSaveService

cancellationToken.ThrowIfCancellationRequested();

var pickedFile = await PickSaveFileAsync(suggestedFileName, fileTypes);
var extensions = FlattenExtensions(fileTypes);
var destinationPath = await _filePickerService.PickSaveAsync("Save As", extensions, suggestedFileName);

if (pickedFile is null) { return null; }
if (destinationPath is null) { return null; }

var parentFolder = await TryGetParentAsync(pickedFile);
var tempInParent = parentFolder is null
? null
: await TryCreateTempFileAsync(parentFolder, pickedFile.Name);
await AtomicFileWriter.WriteAsync(destinationPath, writeContent, cancellationToken);

// Primary path requires write access to the picked file's folder; otherwise fall back to a local temp + copy.
return tempInParent is not null
? await SaveByReplacingAsync(pickedFile, tempInParent, writeContent, cancellationToken)
: await SaveByLocalTempCopyAsync(pickedFile, writeContent, cancellationToken);
return destinationPath;
}

private static async Task<StorageFile?> PickSaveFileAsync(
string suggestedFileName, IReadOnlyDictionary<string, IReadOnlyList<string>> fileTypes)
private static IReadOnlyList<string> FlattenExtensions(
IReadOnlyDictionary<string, IReadOnlyList<string>> fileTypes)
{
if (fileTypes.Count == 0)
{
throw new ArgumentException("At least one file-type choice must be supplied.", nameof(fileTypes));
}

return await MainThread.InvokeOnMainThreadAsync(async () =>
{
var picker = new FileSavePicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
SuggestedFileName = suggestedFileName
};

foreach ((string label, IReadOnlyList<string> extensions) in fileTypes)
{
picker.FileTypeChoices.Add(label, [.. extensions]);
}

PickerHostWindow.Initialize(picker);

return await picker.PickSaveFileAsync();
});
}

private static async Task<string?> SaveByLocalTempCopyAsync(
StorageFile pickedFile, Func<Stream, CancellationToken, Task> writeContent, CancellationToken cancellationToken)
{
string localTempPath = Path.Combine(Path.GetTempPath(), $"eventlogexpert-export-{Guid.NewGuid():N}.tmp");
bool deferred = false;
bool completed = false;
FileUpdateStatus status = FileUpdateStatus.Failed;

try
{
await using (var tempStream = File.Create(localTempPath))
{
await writeContent(tempStream, cancellationToken);
}

cancellationToken.ThrowIfCancellationRequested();

var localTempFile = await StorageFile.GetFileFromPathAsync(localTempPath);

CachedFileManager.DeferUpdates(pickedFile);
deferred = true;

// Final cancellation gate before the uncancellable commit: if cancelled here, the finally resolves the
// deferred-update contract and the picked file is left untouched (CopyAndReplaceAsync never runs).
cancellationToken.ThrowIfCancellationRequested();

// Replace the picked file from the fully-staged temp in a single platform-mediated operation rather than
// truncating then re-filling it ourselves, which would guarantee a zero-length window on any copy fault.
// This is best-effort rather than atomic on provider-backed destinations (see IFileSaveService docs).
await localTempFile.CopyAndReplaceAsync(pickedFile);

status = await CachedFileManager.CompleteUpdatesAsync(pickedFile);
completed = true;
}
finally
{
if (deferred && !completed)
{
try { await CachedFileManager.CompleteUpdatesAsync(pickedFile); }
catch (Exception) { /* best-effort: resolve the provider update contract on failure. */ }
}

try { File.Delete(localTempPath); }
catch (Exception) { /* best-effort: the local temp is a copy source, never the saved output. */ }
}

return ToResultPath(pickedFile, status);
}

private static async Task<string?> SaveByReplacingAsync(
StorageFile pickedFile,
StorageFile tempFile,
Func<Stream, CancellationToken, Task> writeContent,
CancellationToken cancellationToken)
{
bool deferred = false;
bool completed = false;
bool replaced = false;
FileUpdateStatus status = FileUpdateStatus.Failed;
var extensions = fileTypes.Values
.SelectMany(group => group)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();

try
{
CachedFileManager.DeferUpdates(pickedFile);
deferred = true;

await using (var tempStream = await tempFile.OpenStreamForWriteAsync())
{
tempStream.SetLength(0);
await writeContent(tempStream, cancellationToken);
}

cancellationToken.ThrowIfCancellationRequested();

// Atomic commit: the picked file is replaced only after the write succeeds.
await tempFile.MoveAndReplaceAsync(pickedFile);
replaced = true;

status = await CachedFileManager.CompleteUpdatesAsync(pickedFile);
completed = true;
}
finally
{
if (deferred && !completed)
{
try { await CachedFileManager.CompleteUpdatesAsync(pickedFile); }
catch (Exception) { /* best-effort: resolve the provider update contract on failure. */ }
}

if (!replaced)
{
// After a successful replace the temp IS the saved output and must not be deleted.
try { await tempFile.DeleteAsync(); }
catch (Exception) { /* best-effort cleanup. */ }
}
}

return ToResultPath(pickedFile, status);
}

private static string ToResultPath(StorageFile pickedFile, FileUpdateStatus status) =>
status is FileUpdateStatus.Complete or FileUpdateStatus.CompleteAndRenamed
? pickedFile.Path
: throw new IOException($"Save failed with status '{status}'.");

private static async Task<StorageFile?> TryCreateTempFileAsync(StorageFolder folder, string pickedName)
{
try
{
// The save picker may grant access only to the picked file, not its folder; fall back when creation is denied.
return await folder.CreateFileAsync($"{pickedName}.tmp", CreationCollisionOption.GenerateUniqueName);
}
catch (Exception exception) when (exception is UnauthorizedAccessException or COMException)
{
// The parent folder already resolved, so a creation failure here is an access/permission issue; fall back
// to the local-temp copy. Other (unexpected) exceptions propagate so a genuine fault is not masked by
// silently choosing the non-atomic path.
return null;
}
}

private static async Task<StorageFolder?> TryGetParentAsync(StorageFile file)
{
try
{
return await file.GetParentAsync();
}
catch (Exception)
{
return null;
}
return extensions.Count == 0 ?
throw new ArgumentException("At least one extension must be supplied.", nameof(fileTypes)) : extensions;
}
}
28 changes: 14 additions & 14 deletions src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
using System.Globalization;
using Application = Microsoft.Maui.Controls.Application;
using IDispatcher = Fluxor.IDispatcher;
using MauiFilePicker = Microsoft.Maui.Storage.FilePicker;

namespace EventLogExpert.Adapters.Menu;

Expand All @@ -54,6 +53,7 @@ public sealed class MauiMenuActionService(
IUpdateService updateService,
ICurrentVersionProvider currentVersionProvider,
ITraceLogger traceLogger,
IFilePickerService filePickerService,
IFolderPickerService folderPickerService,
IState<EventLogState> eventLogState,
IState<LogTableState> logTableState,
Expand All @@ -71,6 +71,7 @@ public sealed class MauiMenuActionService(
private readonly IState<EventLogState> _eventLogState = eventLogState;
private readonly IEventTableExporter _eventTableExporter = eventTableExporter;
private readonly IExportProgressBannerService _exportProgress = exportProgressBannerService;
private readonly IFilePickerService _filePickerService = filePickerService;
private readonly IFileSaveService _fileSaveService = fileSaveService;
private readonly IFilterLibraryCommands _filterLibraryCommands = filterLibraryCommands;
private readonly IFilterPaneCommands _filterPaneCommands = filterPaneCommands;
Expand Down Expand Up @@ -271,23 +272,22 @@ public Task OpenDocsAsync() =>

public async Task OpenFileAsync(bool combineLog)
{
var options = new PickOptions
{
FileTypes = new FilePickerFileType(
new Dictionary<DevicePlatform, IEnumerable<string>> { { DevicePlatform.WinUI, [".evtx"] } })
};
IReadOnlyList<string> paths;

var files = await MauiFilePicker.Default.PickMultipleAsync(options);
try
{
paths = await _filePickerService.PickMultipleAsync("Open Event Logs", [".evtx"]);
}
catch (InvalidOperationException ex)
{
await _dialogService.ShowAlert("Open File Failed", ex.Message, "Ok");

if (!files.Any()) { return; }
return;
}

var paths = files
.OfType<FileResult>()
.Where(file => !string.IsNullOrEmpty(file.FullPath))
.Select(file => (file.FullPath, LogPathType.File))
.ToList();
if (paths.Count == 0) { return; }

await OpenLogsBatchAsync(paths, combineLog);
await OpenLogsBatchAsync(paths.Select(path => (path, LogPathType.File)), combineLog);
}

public async Task OpenFolderAsync(bool combineLog)
Expand Down
Loading
Loading