From eecea01055bbb0ccfcb4934f0c4c9376216e4134 Mon Sep 17 00:00:00 2001 From: meiameiameia <260650662+meiameiameia@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:08:18 -0300 Subject: [PATCH] Preserve existing profiles when a save is interrupted --- DS4Windows/DS4Control/ProfilePersistence.cs | 49 ++++++++++ DS4Windows/DS4Control/ScpUtil.cs | 5 +- DS4WindowsTests/ProfilePersistenceTests.cs | 102 ++++++++++++++++++++ 3 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 DS4Windows/DS4Control/ProfilePersistence.cs create mode 100644 DS4WindowsTests/ProfilePersistenceTests.cs diff --git a/DS4Windows/DS4Control/ProfilePersistence.cs b/DS4Windows/DS4Control/ProfilePersistence.cs new file mode 100644 index 00000000..42e6f9e3 --- /dev/null +++ b/DS4Windows/DS4Control/ProfilePersistence.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Text; + +namespace DS4Windows +{ + internal static class ProfilePersistence + { + internal static void Save(string path, string xml, + Action writeTemporary = null) + { + path = Path.GetFullPath(path); + string temporary = Path.Combine(Path.GetDirectoryName(path), + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + (writeTemporary ?? WriteTemporary)(temporary, xml); + // Keep the last valid profile until the complete replacement + // is ready. Both files are on the same volume. + if (File.Exists(path)) + File.Replace(temporary, path, null); + else + File.Move(temporary, path); + } + finally + { + if (File.Exists(temporary)) + { + try { File.Delete(temporary); } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + AppLogger.LogToGui("Could not remove an incomplete profile write: " + ex.Message, false); + } + } + } + } + + private static void WriteTemporary(string path, string xml) + { + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, leaveOpen: true)) + { + writer.Write(xml); + writer.Flush(); + } + stream.Flush(flushToDisk: true); + } + } +} diff --git a/DS4Windows/DS4Control/ScpUtil.cs b/DS4Windows/DS4Control/ScpUtil.cs index 65a8e17e..33fbf951 100644 --- a/DS4Windows/DS4Control/ScpUtil.cs +++ b/DS4Windows/DS4Control/ScpUtil.cs @@ -5609,10 +5609,7 @@ public bool SaveProfileNew(int device, string proName) try { - using (StreamWriter sw = new StreamWriter(path, false)) - { - sw.Write(testStr); - } + ProfilePersistence.Save(path, testStr); } catch (UnauthorizedAccessException) { diff --git a/DS4WindowsTests/ProfilePersistenceTests.cs b/DS4WindowsTests/ProfilePersistenceTests.cs new file mode 100644 index 00000000..654318c2 --- /dev/null +++ b/DS4WindowsTests/ProfilePersistenceTests.cs @@ -0,0 +1,102 @@ +using System.Text; +using System.Xml.Linq; +using DS4Windows; + +namespace DS4WindowsTests; + +[TestClass] +[DoNotParallelize] +public sealed class ProfilePersistenceTests +{ + private string directory; + private const string Original = "1,2,3"; + private const string Updated = "4,5,6"; + + [TestInitialize] + public void Initialize() => directory = Directory.CreateTempSubdirectory("ds4w-profile-save-").FullName; + + [TestCleanup] + public void Cleanup() + { + foreach (string path in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) + File.SetAttributes(path, FileAttributes.Normal); + Directory.Delete(directory, recursive: true); + } + + [TestMethod] + public void CreationAndReplacementWriteCompleteUtf8XmlWithoutTemporaryFiles() + { + string path = Path.Combine(directory, "Profile.xml"); + ProfilePersistence.Save(path, Original); + Assert.AreEqual(Original, File.ReadAllText(path)); + ProfilePersistence.Save(path, Updated); + CollectionAssert.AreEqual(new UTF8Encoding(false).GetBytes(Updated), File.ReadAllBytes(path)); + Assert.AreEqual("4,5,6", XDocument.Load(path).Root.Element("Color").Value); + Assert.AreEqual(1, Directory.GetFiles(directory).Length); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public void InterruptedWritePreservesPreviousProfileOrLeavesNewDestinationAbsent(bool existing) + { + string path = Path.Combine(directory, "Profile.xml"); + if (existing) File.WriteAllText(path, Original); + var failure = new IOException("Synthetic interrupted write"); + IOException observed = Assert.ThrowsException(() => ProfilePersistence.Save(path, Updated, + writeTemporary: (temporary, xml) => + { + File.WriteAllText(temporary, ""); + throw failure; + })); + Assert.AreSame(failure, observed); + Assert.AreEqual(existing, File.Exists(path)); + if (existing) Assert.AreEqual(Original, File.ReadAllText(path)); + Assert.AreEqual(existing ? 1 : 0, Directory.GetFiles(directory).Length); + } + + [TestMethod] + public void LockedDestinationPreservesPreviousProfileAndRemovesTemporaryFile() + { + string path = Path.Combine(directory, "Profile.xml"); + File.WriteAllText(path, Original); + using var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + Assert.ThrowsException(() => ProfilePersistence.Save(path, Updated)); + Assert.AreEqual(Original, File.ReadAllText(path)); + Assert.AreEqual(1, Directory.GetFiles(directory).Length); + } + + [TestMethod] + public void ReadOnlyDestinationPreservesPreviousProfileAndRemovesTemporaryFile() + { + string path = Path.Combine(directory, "Profile.xml"); + File.WriteAllText(path, Original); + File.SetAttributes(path, FileAttributes.ReadOnly); + Assert.ThrowsException(() => ProfilePersistence.Save(path, Updated)); + Assert.AreEqual(Original, File.ReadAllText(path)); + Assert.AreEqual(1, Directory.GetFiles(directory).Length); + } + + [TestMethod] + public void ProfileSaveUsesExistingSerializerAndPreservesReadOnlyDestination() + { + string previousRoot = Global.appdatapath; + try + { + Global.appdatapath = directory; + string profiles = Directory.CreateDirectory(Path.Combine(directory, "Profiles")).FullName; + string path = Path.Combine(profiles, "Candidate.xml"); + var store = new BackingStore(); + store.rumble[0] = 77; + Assert.IsTrue(store.SaveProfileNew(0, "Candidate")); + Assert.AreEqual("77", XDocument.Load(path).Root.Element("RumbleBoost").Value); + byte[] previous = File.ReadAllBytes(path); + File.SetAttributes(path, FileAttributes.ReadOnly); + store.rumble[0] = 88; + Assert.IsFalse(store.SaveProfileNew(0, "Candidate")); + CollectionAssert.AreEqual(previous, File.ReadAllBytes(path)); + Assert.AreEqual(1, Directory.GetFiles(profiles).Length); + } + finally { Global.appdatapath = previousRoot; } + } +}