Skip to content
9 changes: 9 additions & 0 deletions .global.editorconfig.ini
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ dotnet_diagnostic.CA2229.severity = silent
# Opt in to preview features before using them
dotnet_diagnostic.CA2252.severity = silent # CSharpDetectPreviewFeatureAnalyzer very slow

## Nullable rules; generics are a bit wonky and I have no idea why we're allowed to configure these to be not errors or why they aren't errors by default.

# Nullability of reference types in value doesn't match target type.
dotnet_diagnostic.CS8619.severity = error
# Make Foo<string?> an error for class Foo<T> where T : class. Use `where T : class?` if Foo<string?> should be allowed.
dotnet_diagnostic.CS8634.severity = error
# Nullability of type argument doesn't match 'notnull' constraint.
dotnet_diagnostic.CS8714.severity = error

## .NET DocumentationAnalyzers style rules

# Place text in paragraphs
Expand Down
12 changes: 11 additions & 1 deletion src/BizHawk.Client.Common/Api/Classes/EmuClientApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,19 @@ public bool OpenRom(string path)

public void RebootCore() => _mainForm.RebootCore();

// TODO: Change return type to FileWriteResult.
public void SaveRam() => _mainForm.FlushSaveRAM();

public void SaveState(string name) => _mainForm.SaveState(Path.Combine(_config.PathEntries.SaveStateAbsolutePath(Game.System), $"{name}.State"), name, fromLua: false);
// TODO: Change return type to FileWriteResult.
// We may wish to change more than that, since we have a mostly-dupicate ISaveStateApi.Save, neither has documentation indicating what the differences are.
public void SaveState(string name)
{
FileWriteResult result = _mainForm.SaveState(Path.Combine(_config.PathEntries.SaveStateAbsolutePath(Game.System), $"{name}.State"), name);
if (result.Exception != null && result.Exception is not UnlessUsingApiException)
{
throw result.Exception;
}
}

public int ScreenHeight() => _displayManager.GetPanelNativeSize().Height;

Expand Down
4 changes: 3 additions & 1 deletion src/BizHawk.Client.Common/Api/Classes/MovieApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public string GetInputAsMnemonic(int frame)
return Bk2LogEntryGenerator.GenerateLogEntry(_movieSession.Movie.GetInputState(frame));
}

// TODO: Change return type to FileWriteResult
public void Save(string filename)
{
if (_movieSession.Movie.NotActive())
Expand All @@ -70,7 +71,8 @@ public void Save(string filename)
}
_movieSession.Movie.Filename = filename;
}
_movieSession.Movie.Save();
FileWriteResult result = _movieSession.Movie.Save();
if (result.Exception != null) throw result.Exception;
}

public IReadOnlyDictionary<string, string> GetHeader()
Expand Down
17 changes: 15 additions & 2 deletions src/BizHawk.Client.Common/Api/Classes/SaveStateApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,17 @@ public bool LoadSlot(int slotNum, bool suppressOSD)
return _mainForm.LoadQuickSave(slotNum, suppressOSD: suppressOSD);
}

public void Save(string path, bool suppressOSD) => _mainForm.SaveState(path, path, true, suppressOSD);
// TODO: Change return type FileWriteResult.
public void Save(string path, bool suppressOSD)
{
FileWriteResult result = _mainForm.SaveState(path, path, suppressOSD);
if (result.Exception != null && result.Exception is not UnlessUsingApiException)
{
throw result.Exception;
}
}

// TODO: Change return type to FileWriteResult.
public void SaveSlot(int slotNum, bool suppressOSD)
{
if (slotNum is < 0 or > 10) throw new ArgumentOutOfRangeException(paramName: nameof(slotNum), message: ERR_MSG_NOT_A_SLOT);
Expand All @@ -49,7 +58,11 @@ public void SaveSlot(int slotNum, bool suppressOSD)
LogCallback(ERR_MSG_USE_SLOT_10);
slotNum = 10;
}
_mainForm.SaveQuickSave(slotNum, suppressOSD: suppressOSD, fromLua: true);
FileWriteResult result = _mainForm.SaveQuickSave(slotNum, suppressOSD: suppressOSD);
if (result.Exception != null && result.Exception is not UnlessUsingApiException)
{
throw result.Exception;
}
}
}
}
49 changes: 49 additions & 0 deletions src/BizHawk.Client.Common/DialogControllerExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
#nullable enable

using System.Collections.Generic;
using System.Diagnostics;

namespace BizHawk.Client.Common
{
public enum TryAgainResult
{
Saved,
IgnoredFailure,
Canceled,
}

public static class DialogControllerExtensions
{
public static void AddOnScreenMessage(this IDialogParent dialogParent, string message, int? duration = null)
Expand Down Expand Up @@ -60,6 +68,47 @@ public static bool ModalMessageBox2(
EMsgBoxIcon? icon = null)
=> dialogParent.DialogController.ShowMessageBox3(owner: dialogParent, text: text, caption: caption, icon: icon);

public static void ErrorMessageBox(
this IDialogParent dialogParent,
FileWriteResult fileResult,
string? prefixMessage = null)
{
Debug.Assert(fileResult.IsError && fileResult.Exception != null, "Error box must have an error.");

string prefix = prefixMessage ?? "";
dialogParent.ModalMessageBox(
text: $"{prefix}\n{fileResult.UserFriendlyErrorMessage()}\n{fileResult.Exception!.Message}",
caption: "Error",
icon: EMsgBoxIcon.Error);
}

/// <summary>
/// If the action fails, asks the user if they want to try again.
/// The user will be repeatedly asked if they want to try again until either success or the user says no.
/// </summary>
/// <returns>Returns true on success or if the user said no. Returns false if the user said cancel.</returns>
public static TryAgainResult DoWithTryAgainBox(
this IDialogParent dialogParent,
Func<FileWriteResult> action,
string message)
{
FileWriteResult fileResult = action();
while (fileResult.IsError)
{
string prefix = message ?? "";
bool? askResult = dialogParent.ModalMessageBox3(
text: $"{prefix} Do you want to try again?\n\nError details:" +
$"{fileResult.UserFriendlyErrorMessage()}\n{fileResult.Exception!.Message}",
caption: "Error",
icon: EMsgBoxIcon.Error);
if (askResult == null) return TryAgainResult.Canceled;
if (askResult == false) return TryAgainResult.IgnoredFailure;
if (askResult == true) fileResult = action();
}

return TryAgainResult.Saved;
}

/// <summary>Creates and shows a <c>System.Windows.Forms.OpenFileDialog</c> or equivalent with the receiver (<paramref name="dialogParent"/>) as its parent</summary>
/// <param name="discardCWDChange"><c>OpenFileDialog.RestoreDirectory</c> (isn't this useless when specifying <paramref name="initDir"/>? keeping it for backcompat)</param>
/// <param name="filter"><c>OpenFileDialog.Filter</c></param>
Expand Down
131 changes: 131 additions & 0 deletions src/BizHawk.Client.Common/FileWriteResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#nullable enable

using System.Diagnostics;

namespace BizHawk.Client.Common
{
public enum FileWriteEnum
{
Success,
// Failures during a FileWriter write.
FailedToOpen,
FailedDuringWrite,
FailedToDeleteOldBackup,
FailedToMakeBackup,
FailedToDeleteOldFile,
FailedToRename,
Aborted,
// Failures from other sources
FailedToDeleteGeneric,
FailedToMoveForSwap,
}

/// <summary>
/// Provides information about the success or failure of an attempt to write to a file.
/// </summary>
public class FileWriteResult
{
public readonly FileWriteEnum Error = FileWriteEnum.Success;
public readonly Exception? Exception;
internal readonly FileWritePaths Paths;

public bool IsError => Error != FileWriteEnum.Success;

public FileWriteResult(FileWriteEnum error, FileWritePaths writer, Exception? exception)
{
Error = error;
Exception = exception;
Paths = writer;
}

public FileWriteResult() : this(FileWriteEnum.Success, new("", ""), null) { }

/// <summary>
/// Converts this instance to a different generic type.
/// The new instance will take the value given only if this instance has no error.
/// </summary>
/// <param name="value">The value of the new instance. Ignored if this instance has an error.</param>
public FileWriteResult<T> Convert<T>(T value) where T : class
{
if (Error == FileWriteEnum.Success) return new(value, Paths);
else return new(this);
}

public FileWriteResult(FileWriteResult other) : this(other.Error, other.Paths, other.Exception) { }

public string UserFriendlyErrorMessage()
{
Debug.Assert(!IsError || (Exception != null), "FileWriteResult with an error should have an exception.");

switch (Error)
{
// We include the full path since the user may not have explicitly given a directory and may not know what it is.
case FileWriteEnum.Success:
return $"The file \"{Paths.Final}\" was written successfully.";
case FileWriteEnum.FailedToOpen:
if (Paths.Final != Paths.Temp)
{
return $"The temporary file \"{Paths.Temp}\" could not be opened.";
}
return $"The file \"{Paths.Final}\" could not be created.";
case FileWriteEnum.FailedDuringWrite:
return $"An error occurred while writing the file."; // No file name here; it should be deleted.
case FileWriteEnum.Aborted:
return "The operation was aborted.";

case FileWriteEnum.FailedToDeleteGeneric:
return $"The file \"{Paths.Final}\" could not be deleted.";
//case FileWriteEnum.FailedToDeleteForSwap:
// return $"Failed to swap files. Unable to write to \"{Paths.Final}\"";
case FileWriteEnum.FailedToMoveForSwap:
return $"Failed to swap files. Unable to rename \"{Paths.Temp}\" to \"{Paths.Final}\"";
}

string success = $"The file was created successfully at \"{Paths.Temp}\" but could not be moved";
switch (Error)
{
case FileWriteEnum.FailedToDeleteOldBackup:
return $"{success}. Unable to remove old backup file \"{Paths.Backup}\".";
case FileWriteEnum.FailedToMakeBackup:
return $"{success}. Unable to create backup. Failed to move \"{Paths.Final}\" to \"{Paths.Backup}\".";
case FileWriteEnum.FailedToDeleteOldFile:
return $"{success}. Unable to remove the old file \"{Paths.Final}\".";
case FileWriteEnum.FailedToRename:
return $"{success} to \"{Paths.Final}\".";
default:
return "unreachable";
}
}
}

/// <summary>
/// Provides information about the success or failure of an attempt to write to a file.
/// If successful, also provides a related object instance.
/// </summary>
public class FileWriteResult<T> : FileWriteResult where T : class // Note: "class" also means "notnull".
{
/// <summary>
/// Value will be null if <see cref="FileWriteResult.IsError"/> is true.
/// Otherwise, Value will not be null.
/// </summary>
public readonly T? Value = default;

internal FileWriteResult(FileWriteEnum error, FileWritePaths paths, Exception? exception) : base(error, paths, exception) { }

internal FileWriteResult(T value, FileWritePaths paths) : base(FileWriteEnum.Success, paths, null)
{
Debug.Assert(value != null, "Should not give a null value on success. Use the non-generic type if there is no value.");
Value = value;
}

public FileWriteResult(FileWriteResult other) : base(other.Error, other.Paths, other.Exception) { }
}

/// <summary>
/// This only exists as a way to avoid changing the API behavior.
/// </summary>
public class UnlessUsingApiException : Exception
{
public UnlessUsingApiException(string message) : base(message) { }
}
}
Loading