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 1/6] 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; } + } +} From ae9eeaf4fe4710f623ceecca91775ed372510e3e Mon Sep 17 00:00:00 2001 From: meiameiameia <260650662+meiameiameia@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:08:19 -0300 Subject: [PATCH 2/6] Retain output slot registration until disconnect succeeds --- DS4Windows/DS4Control/OutputSlotManager.cs | 18 ++- DS4WindowsTests/OutputSlotRemovalTests.cs | 152 +++++++++++++++++++++ 2 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 DS4WindowsTests/OutputSlotRemovalTests.cs diff --git a/DS4Windows/DS4Control/OutputSlotManager.cs b/DS4Windows/DS4Control/OutputSlotManager.cs index 3def1b2c..ebf6fe0e 100644 --- a/DS4Windows/DS4Control/OutputSlotManager.cs +++ b/DS4Windows/DS4Control/OutputSlotManager.cs @@ -299,10 +299,6 @@ public void DeferredRemoval(OutputDevice outputDevice, int inIdx, { ControlService.StartupDiag($"OutputSlotManager.DeferredRemoval found slot={slot + 1} type={outputDevice.GetDeviceType()}"); //int slot = revDeviceDict[outputDevice]; - outputDevices[slot] = null; - deviceDict.Remove(slot); - revDeviceDict.Remove(outputDevice); - ControlService.StartupDiag($"OutputSlotManager.RemoveFeedbacks begin slot={slot + 1}"); outputDevice.RemoveFeedbacks(); ControlService.StartupDiag($"OutputSlotManager.RemoveFeedbacks end slot={slot + 1}"); @@ -310,6 +306,12 @@ public void DeferredRemoval(OutputDevice outputDevice, int inIdx, outputDevice.Disconnect(); ControlService.StartupDiag($"OutputSlotManager.Disconnect end slot={slot + 1}"); + // Failed teardown must retain the exact registration so + // this slot cannot be reused and removal can be retried. + outputDevices[slot] = null; + deviceDict.Remove(slot); + revDeviceDict.Remove(outputDevice); + if (inIdx != -1) { outdevs[inIdx] = null; @@ -549,8 +551,8 @@ internal void RetireHeldOutput(OutputDevice output, OutputDevice[] bound, int in !revDeviceDict.TryGetValue(output, out int slot) || !IsExactAttachedSlotNoLock(outputSlots[slot], output) || outputSlots[slot].InputIndex != index) throw new InvalidOperationException("The retained output no longer has its exact reservation."); - // Unlike legacy deferred removal, keep the exact manager record - // until Disconnect succeeds. A failure remains retryable at Stop. + // Keep the exact manager record until Disconnect succeeds. + // A failure remains retryable at Stop. output.RemoveFeedbacks(); output.Disconnect(); outputDevices[slot] = null; @@ -587,9 +589,11 @@ public void UnplugRemainingControllers(bool immediate=false) { if (device.OutputDevice != null) { - outputDevices[slotIdx] = null; device.OutputDevice.Disconnect(); + outputDevices[slotIdx] = null; + deviceDict.Remove(slotIdx); + revDeviceDict.Remove(device.OutputDevice); device.DetachDevice(); SlotUnassigned?.Invoke(this, slotIdx, outputSlots[slotIdx]); //if (!immediate) diff --git a/DS4WindowsTests/OutputSlotRemovalTests.cs b/DS4WindowsTests/OutputSlotRemovalTests.cs new file mode 100644 index 00000000..7f12654f --- /dev/null +++ b/DS4WindowsTests/OutputSlotRemovalTests.cs @@ -0,0 +1,152 @@ +using DS4Windows; +using DS4WinWPF.DS4Control; + +namespace DS4WindowsTests; + +[TestClass] +[DoNotParallelize] +public sealed class OutputSlotRemovalTests +{ + [DataTestMethod] + [DataRow(false, -1)] + [DataRow(true, -1)] + [DataRow(false, 0)] + [DataRow(true, 0)] + public void FailedRemovalRetainsExactSlotAndBindingUntilRetrySucceeds(bool failFeedback, int input) + { + var manager = new OutputSlotManager(); + var bound = new OutputDevice[2]; + var output = new FakeOutput(); + var other = new FakeOutput(); + OutSlotDevice slot = manager.DeferredPlugin(output, input, "First", bound, OutContType.ViiperX360); + int unassigned = 0; + OutputSlotManager.SlotUnassignedDelegate onUnassigned = (_, index, detached) => + { + unassigned++; + Assert.IsNull(detached.OutputDevice); + Assert.AreEqual(OutSlotDevice.AttachedStatus.UnAttached, detached.CurrentAttachedStatus); + Assert.IsNull(manager.GetOutSlotDevice(index).OutputDevice); + if (ReferenceEquals(detached, slot)) + { + Assert.IsNull(manager.GetOutSlotDevice(output)); + if (input >= 0) Assert.IsNull(bound[input]); + } + }; + manager.SlotUnassigned += onUnassigned; + if (failFeedback) output.FailFeedback = true; + else output.FailDisconnect = true; + try + { + Assert.ThrowsException(() => manager.DeferredRemoval(output, input, bound)); + Assert.AreSame(slot, manager.GetOutSlotDevice(output)); + Assert.AreSame(output, slot.OutputDevice); + Assert.AreEqual(OutSlotDevice.AttachedStatus.Attached, slot.CurrentAttachedStatus); + Assert.AreEqual(1, manager.NumAttachedDevices); + Assert.AreEqual(0, unassigned); + Assert.AreEqual(failFeedback ? 0 : 1, output.DisconnectAttempts); + if (input >= 0) + { + Assert.AreSame(output, bound[input]); + Assert.IsTrue(manager.IsExactBoundOutput(output, input)); + } + + OutSlotDevice otherSlot = manager.DeferredPlugin(other, 1, "Second", bound, OutContType.ViiperX360); + Assert.AreNotSame(slot, otherSlot, "Failed removal must not expose its slot for another output."); + output.FailFeedback = output.FailDisconnect = false; + manager.DeferredRemoval(output, input, bound); + Assert.IsNull(manager.GetOutSlotDevice(output)); + Assert.AreEqual(1, manager.NumAttachedDevices); + Assert.AreEqual(1, unassigned); + Assert.IsTrue(manager.IsExactBoundOutput(other, 1)); + int attempts = output.DisconnectAttempts; + manager.DeferredRemoval(output, input, bound); + Assert.AreEqual(attempts, output.DisconnectAttempts); + Assert.AreEqual(1, unassigned); + } + finally + { + output.FailFeedback = output.FailDisconnect = false; + manager.SlotUnassigned -= onUnassigned; + manager.Stop(); + } + } + + [TestMethod] + public void FailedStopKeepsFailedSlotRegisteredAndDoesNotRepeatSuccessfulDisconnections() + { + var manager = new OutputSlotManager(); + var bound = new OutputDevice[2]; + var first = new FakeOutput(); + var second = new FakeOutput(); + manager.DeferredPlugin(first, 0, "First", bound, OutContType.ViiperX360); + OutSlotDevice secondSlot = manager.DeferredPlugin(second, 1, "Second", bound, OutContType.ViiperX360); + int unassigned = 0; + manager.SlotUnassigned += (_, _, _) => unassigned++; + second.FailDisconnect = true; + try + { + Assert.ThrowsException(() => manager.Stop()); + Assert.IsNull(manager.GetOutSlotDevice(first)); + Assert.AreSame(secondSlot, manager.GetOutSlotDevice(second)); + Assert.IsTrue(manager.IsExactBoundOutput(second, 1)); + Assert.AreEqual(1, manager.NumAttachedDevices); + Assert.AreEqual(1, unassigned); + second.FailDisconnect = false; + manager.Stop(); + Assert.AreEqual(1, first.DisconnectAttempts); + Assert.AreEqual(2, second.DisconnectAttempts); + Assert.AreEqual(0, manager.NumAttachedDevices); + Assert.IsNull(manager.GetOutSlotDevice(second)); + Assert.AreEqual(2, unassigned); + } + finally + { + second.FailDisconnect = false; + manager.Stop(); + } + } + + [TestMethod] + public void SuccessfulRemovalKeepsPermanentSlotPreferenceAndAllowsFreshBinding() + { + var manager = new OutputSlotManager(); + var bound = new OutputDevice[1]; + var output = new FakeOutput(); + OutSlotDevice slot = manager.DeferredPlugin(output, 0, "First", bound, OutContType.ViiperX360); + slot.CurrentReserveStatus = OutSlotDevice.ReserveStatus.Permanent; + try + { + manager.DeferredRemoval(output, 0, bound); + Assert.AreEqual(OutSlotDevice.ReserveStatus.Permanent, slot.CurrentReserveStatus); + Assert.AreEqual(OutContType.ViiperX360, slot.PermanentType); + Assert.IsNull(bound[0]); + var replacement = new FakeOutput(); + Assert.AreSame(slot, manager.DeferredPlugin(replacement, 0, "Replacement", bound, + OutContType.ViiperX360, slot)); + Assert.IsTrue(manager.IsExactBoundOutput(replacement, 0)); + } + finally { manager.Stop(); } + } + + // Synthetic methods never create a virtual device or touch a driver. + private sealed class FakeOutput : OutputDevice + { + internal bool FailFeedback, FailDisconnect; + internal int DisconnectAttempts; + public override void Connect() => connected = true; + public override void Disconnect() + { + DisconnectAttempts++; + if (FailDisconnect) throw new IOException("Synthetic disconnect failure"); + connected = false; + } + public override void RemoveFeedbacks() + { + if (FailFeedback) throw new IOException("Synthetic feedback failure"); + } + public override string GetDeviceType() => OutContType.ViiperX360.ToString(); + public override void ResetState(bool submit = true) { } + public override void RemoveFeedback(int inIdx) { } + public override void ConvertandSendReport(DS4State state, int device) { } + } +} From 6e0bc15c6ad36229865c55c3dcbb616aa96aa8af Mon Sep 17 00:00:00 2001 From: meiameiameia <260650662+meiameiameia@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:49:13 -0300 Subject: [PATCH 3/6] Respect mapped touchpad clicks in PlayStation output reports --- .../DS4Control/Viiper/ViiperOutDevice.cs | 6 +- DS4WindowsTests/ViiperTouchpadClickTests.cs | 228 ++++++++++++++++++ 2 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 DS4WindowsTests/ViiperTouchpadClickTests.cs diff --git a/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs b/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs index 71fca344..01294515 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs @@ -13340,7 +13340,8 @@ private static ushort BuildDualShock4Buttons(DS4State state) if (state.L3) buttons |= 0x4000; if (state.R3) buttons |= 0x8000; if (state.PS) buttons |= 0x0001; - if (state.OutputTouchButton || state.TouchButton) buttons |= 0x0002; + // The mapper owns click passthrough; TouchButton is the physical observation. + if (state.OutputTouchButton) buttons |= 0x0002; return buttons; } @@ -13360,7 +13361,8 @@ private static uint BuildDualSenseButtons(DS4State state) if (state.L3) buttons |= 0x00004000; if (state.R3) buttons |= 0x00008000; if (state.PS) buttons |= 0x00010000; - if (state.OutputTouchButton || state.TouchButton) buttons |= 0x00020000; + // The mapper owns click passthrough; TouchButton is the physical observation. + if (state.OutputTouchButton) buttons |= 0x00020000; if (state.Mute) buttons |= 0x00040000; if (state.FnL) buttons |= 0x00100000; if (state.FnR) buttons |= 0x00200000; diff --git a/DS4WindowsTests/ViiperTouchpadClickTests.cs b/DS4WindowsTests/ViiperTouchpadClickTests.cs new file mode 100644 index 00000000..c119d9ca --- /dev/null +++ b/DS4WindowsTests/ViiperTouchpadClickTests.cs @@ -0,0 +1,228 @@ +using System.Buffers.Binary; +using System.Reflection; +using System.Runtime.CompilerServices; +using DS4Windows; +using DS4Windows.DS4Control; +using DS4Windows.InputDevices; + +namespace DS4WindowsTests; + +[TestClass] +[DoNotParallelize] +public sealed class ViiperTouchpadClickTests +{ + private const int Slot = 7; + private static readonly FieldInfo StoreField = typeof(Global).GetField( + "m_Config", BindingFlags.Static | BindingFlags.NonPublic)!; + private BackingStore previousStore; + private VirtualKBMBase previousHandler; + private VirtualKBMMapping previousMapping; + private Mapping.SyntheticState previousGlobal; + private Mapping.SyntheticState[] previousDevices; + private DS4StateFieldMapping[] previousFields, previousOutputFields; + private Mapping.TwoStageTriggerMappingData[] previousLeftStages, previousRightStages; + private NoHidDevice device; + private Mouse mouse; + private ControlService service; + private RecordingHandler handler; + + [TestInitialize] + public void Initialize() + { + previousStore = Global.store; + previousHandler = Global.outputKBMHandler; + previousMapping = Global.outputKBMMapping; + previousGlobal = Mapping.globalState; + previousDevices = Mapping.deviceState; + previousFields = Mapping.fieldMappings; + previousOutputFields = Mapping.outputFieldMappings; + previousLeftStages = Mapping.l2TwoStageMappingData; + previousRightStages = Mapping.r2TwoStageMappingData; + StoreField.SetValue(null, new BackingStore()); + Global.outputKBMHandler = handler = new RecordingHandler(); + var mapping = new SendInputMapping(); + mapping.PopulateConstants(); + mapping.PopulateMappings(); + Global.outputKBMMapping = mapping; + Mapping.globalState = new(); + Mapping.deviceState = Enumerable.Range(0, Global.MAX_DS4_CONTROLLER_COUNT) + .Select(_ => new Mapping.SyntheticState()).ToArray(); + Mapping.fieldMappings = Enumerable.Range(0, Global.MAX_DS4_CONTROLLER_COUNT) + .Select(_ => new DS4StateFieldMapping()).ToArray(); + Mapping.outputFieldMappings = Enumerable.Range(0, Global.MAX_DS4_CONTROLLER_COUNT) + .Select(_ => new DS4StateFieldMapping()).ToArray(); + Mapping.l2TwoStageMappingData = Enumerable.Range(0, Global.MAX_DS4_CONTROLLER_COUNT) + .Select(_ => new Mapping.TwoStageTriggerMappingData()).ToArray(); + Mapping.r2TwoStageMappingData = Enumerable.Range(0, Global.MAX_DS4_CONTROLLER_COUNT) + .Select(_ => new Mapping.TwoStageTriggerMappingData()).ToArray(); + device = new NoHidDevice(); + mouse = new Mouse(Slot, device); + service = (ControlService)RuntimeHelpers.GetUninitializedObject(typeof(ControlService)); + service.DS4Controllers = new DS4Device[Global.MAX_DS4_CONTROLLER_COUNT]; + service.DS4Controllers[Slot] = device; + Global.store.profileActions[Slot].Clear(); + Global.store.profileActionCount[Slot] = 0; + } + + [TestCleanup] + public void Cleanup() + { + Mapping.DiscardPostMapStickData(Slot); + StoreField.SetValue(null, previousStore); + Global.outputKBMHandler = previousHandler; + Global.outputKBMMapping = previousMapping; + Mapping.globalState = previousGlobal; + Mapping.deviceState = previousDevices; + Mapping.fieldMappings = previousFields; + Mapping.outputFieldMappings = previousOutputFields; + Mapping.l2TwoStageMappingData = previousLeftStages; + Mapping.r2TwoStageMappingData = previousRightStages; + } + + [DataTestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public void WireClickFollowsMappedStateRatherThanPhysicalClick(bool physical, bool mapped) + { + AssertWireClick(new DS4State { TouchButton = physical, OutputTouchButton = mapped }, mapped); + } + + [DataTestMethod] + [DataRow(TouchpadOutMode.Controls, false)] + [DataRow(TouchpadOutMode.Controls, true)] + [DataRow(TouchpadOutMode.Mouse, false)] + [DataRow(TouchpadOutMode.Mouse, true)] + [DataRow(TouchpadOutMode.MouseJoystick, false)] + [DataRow(TouchpadOutMode.MouseJoystick, true)] + [DataRow(TouchpadOutMode.AbsoluteMouse, false)] + [DataRow(TouchpadOutMode.AbsoluteMouse, true)] + [DataRow(TouchpadOutMode.Passthru, false)] + [DataRow(TouchpadOutMode.Passthru, true)] + public void TouchpadPolicySurvivesPacketSerialization(TouchpadOutMode mode, bool clickPassthrough) + { + Global.TouchOutMode[Slot] = mode; + Global.TouchClickPassthru[Slot] = clickPassthrough; + DS4State source = device.getCurrentStateRef(); + source.TouchButton = source.OutputTouchButton = true; + mouse.touchButtonDown(null, new TouchpadEventArgs(DateTime.UtcNow, true, false, null)); + Assert.IsTrue(source.TouchButton, "The physical observation must remain available."); + AssertWireClick(Map(source), mode == TouchpadOutMode.Passthru || clickPassthrough); + } + + [DataTestMethod] + [DataRow(DS4Controls.TouchLeft, false)] + [DataRow(DS4Controls.TouchLeft, true)] + [DataRow(DS4Controls.TouchRight, false)] + [DataRow(DS4Controls.TouchRight, true)] + [DataRow(DS4Controls.TouchUpper, false)] + [DataRow(DS4Controls.TouchUpper, true)] + [DataRow(DS4Controls.TouchMulti, false)] + [DataRow(DS4Controls.TouchMulti, true)] + public void RemappedTouchRegionEmitsOnlyItsSelectedOutput(DS4Controls region, bool controllerClick) + { + Global.TouchOutMode[Slot] = TouchpadOutMode.Controls; + Global.TouchClickPassthru[Slot] = false; + DS4ControlSettings setting = Global.store.GetDS4CSetting(Slot, region); + setting.UpdateSettings(false, controllerClick ? (object)X360Controls.TouchpadClick : 0x4D, + string.Empty, DS4KeyType.None); + if (!controllerClick) setting.action.actionAlias = 0x4D; + DS4State source = device.getCurrentStateRef(); + source.TouchButton = source.OutputTouchButton = source.Touch1 = true; + Touch first = region == DS4Controls.TouchUpper ? null : + new Touch(region == DS4Controls.TouchRight ? 1500 : 400, 400, 0, null); + Touch second = region == DS4Controls.TouchMulti ? new Touch(1500, 400, 1, null) : null; + mouse.touchButtonDown(null, new TouchpadEventArgs(DateTime.UtcNow, true, first != null, first, second)); + Assert.IsTrue(region switch + { + DS4Controls.TouchLeft => mouse.leftDown, + DS4Controls.TouchRight => mouse.rightDown, + DS4Controls.TouchUpper => mouse.upperDown, + _ => mouse.multiDown, + }, "The real touch handler must select the requested region."); + DS4State mapped = Map(source); + Mapping.Commit(Slot); + AssertWireClick(mapped, controllerClick); + Assert.AreEqual(!controllerClick, handler.Keys.Contains(0x4D)); + source.TouchButton = source.OutputTouchButton = false; + mouse.touchButtonUp(null, new TouchpadEventArgs(DateTime.UtcNow, false, first != null, first, second)); + AssertWireClick(Map(source), false); + Mapping.Commit(Slot); + Assert.AreEqual(0, handler.Keys.Count, "Releasing the touch must release the keyboard mapping."); + } + + [TestMethod] + public void ControllerMacroClickSurvivesMappingAndSerialization() + { + var flags = BindingFlags.Static | BindingFlags.NonPublic; + FieldInfo countField = typeof(Mapping).GetField("macroCount", flags)!; + FieldInfo controlsField = typeof(Mapping).GetField("macroControl", flags)!; + object previousCount = countField.GetValue(null); + object previousControls = controlsField.GetValue(null); + try + { + var controls = new bool[26]; + controls[25] = true; // The existing controller macro touch-click output. + controlsField.SetValue(null, controls); + countField.SetValue(null, 1u); + AssertWireClick(Map(new DS4State()), true); + controls[25] = false; + AssertWireClick(Map(new DS4State()), false); + } + finally + { + countField.SetValue(null, previousCount); + controlsField.SetValue(null, previousControls); + } + } + + private DS4State Map(DS4State source) + { + source.elapsedTime = .004; + var mapped = new DS4State(); + source.CopyExtrasTo(mapped); + Mapping.MapCustom(Slot, source, mapped, new DS4StateExposed(source), mouse, service); + return mapped; + } + + private static void AssertWireClick(DS4State state, bool expected) + { + foreach (var type in new[] { ViiperVirtualDeviceType.DualShock4, + ViiperVirtualDeviceType.DualSense, ViiperVirtualDeviceType.DualSenseEdge }) + { + byte[] packet = ViiperStatePacketBuilder.Build(type, state, -1); + uint buttons = type == ViiperVirtualDeviceType.DualShock4 + ? BinaryPrimitives.ReadUInt16LittleEndian(packet.AsSpan(4)) + : BinaryPrimitives.ReadUInt32LittleEndian(packet.AsSpan(4)); + uint mask = type == ViiperVirtualDeviceType.DualShock4 ? 2u : 0x20000u; + Assert.AreEqual(expected, (buttons & mask) != 0, type.ToString()); + } + } + + private sealed class NoHidDevice : DS4Device + { + internal NoHidDevice() : base("Synthetic touchpad mapping", InputDeviceType.DualSense, + ConnectionType.BT) { } + } + + private sealed class RecordingHandler : VirtualKBMBase + { + internal readonly HashSet Keys = new(); + public override bool Connect() => throw new AssertFailedException("No system input allowed."); + public override bool Disconnect() => throw new AssertFailedException("No system input allowed."); + public override void MoveRelativeMouse(int x, int y) => Assert.Fail("Unexpected mouse movement."); + public override void MoveAbsoluteMouse(double x, double y) => Assert.Fail("Unexpected absolute mouse."); + public override void PerformMouseWheelEvent(int vertical, int horizontal) => Assert.Fail("Unexpected wheel."); + public override void PerformMouseButtonEvent(uint button) { } + public override void PerformMouseButtonPress(uint button) { } + public override void PerformMouseButtonRelease(uint button) { } + public override void PerformKeyPress(uint key) => Keys.Add(key); + public override void PerformKeyPressAlt(uint key) => Keys.Add(key); + public override void PerformKeyRelease(uint key) => Keys.Remove(key); + public override void PerformKeyReleaseAlt(uint key) => Keys.Remove(key); + public override string GetDisplayName() => "Synthetic touchpad mapping"; + public override string GetIdentifier() => GetDisplayName(); + public override string GetFullDisplayName() => GetDisplayName(); + } +} From 8610be47d32a98d9728c1adc3678fc1ebeebbe1b Mon Sep 17 00:00:00 2001 From: meiameiameia <260650662+meiameiameia@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:49:14 -0300 Subject: [PATCH 4/6] Request controller removal for Bluetooth disconnect special actions --- DS4Windows/DS4Control/Mapping.cs | 2 +- .../DualSenseManualDisconnectTests.cs | 85 +++++++++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/DS4Windows/DS4Control/Mapping.cs b/DS4Windows/DS4Control/Mapping.cs index d47a09e7..680dc0c5 100644 --- a/DS4Windows/DS4Control/Mapping.cs +++ b/DS4Windows/DS4Control/Mapping.cs @@ -5302,7 +5302,7 @@ private static async void MapCustomAction(int device, DS4State cState, DS4State //bool exclusive = /*tempBool =*/ d.isExclusive(); if (deviceConn == ConnectionType.BT) { - d.DisconnectBT(); + d.DisconnectBT(callRemoval: true); ReleaseActionKeys(action, device); return; } diff --git a/DS4WindowsTests/DualSenseManualDisconnectTests.cs b/DS4WindowsTests/DualSenseManualDisconnectTests.cs index cdfdc04b..ab95c177 100644 --- a/DS4WindowsTests/DualSenseManualDisconnectTests.cs +++ b/DS4WindowsTests/DualSenseManualDisconnectTests.cs @@ -16,13 +16,17 @@ public sealed class DualSenseManualDisconnectTests { private const BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + public enum DisconnectSource { ControllerList, Tray, SpecialAction } + [DataTestMethod] - [DataRow(false)] - [DataRow(true)] - public void ManualDisconnectRemovesOnlyItsRowWithoutWaitingForHidReadFailure(bool fromTray) + [DataRow(DisconnectSource.ControllerList)] + [DataRow(DisconnectSource.Tray)] + [DataRow(DisconnectSource.SpecialAction)] + public void RequestedDisconnectRemovesOnlyItsRowWithoutWaitingForHidReadFailure(DisconnectSource source) { OnSta(() => { + Assert.IsNull(Application.Current, "The fixture must not dispatch settings saves through a live app."); var model = new ControllerListViewModel(new ProfileList()); var device = Device(); var other = Device(); @@ -60,8 +64,13 @@ public void ManualDisconnectRemovesOnlyItsRowWithoutWaitingForHidReadFailure(boo outputOwner = Worker(device, "physicalOutputThread"); lifecycleOwner = Worker(device, "physicalLifecycleThread"); - if (fromTray) + if (source == DisconnectSource.Tray) ClickTrayDisconnect(device); + else if (source == DisconnectSource.SpecialAction) + { + device.queueEvent(() => RunSpecialAction(device, 2)); + InvokeDevice(device, "DrainQueuedInputEvents"); + } else { model.ControllerDict[2].RequestDisconnect(); @@ -71,7 +80,7 @@ public void ManualDisconnectRemovesOnlyItsRowWithoutWaitingForHidReadFailure(boo Assert.IsTrue(radioReached.Wait(5000), "Manual Disconnect did not reach the radio boundary."); Assert.IsTrue(lifecycleOwner.Join(5000), "Disconnect lifecycle did not finish."); Assert.AreEqual(true, requestedRemoval, - "Manual Disconnect must remove the row even if the HID failure arrives after lifecycle retirement."); + "Requested Disconnect must remove the row even if the HID failure arrives after lifecycle retirement."); Assert.AreEqual(1, radioCalls); Assert.AreEqual(1, finalizations); Assert.AreEqual(1, removalCalls); @@ -121,6 +130,72 @@ private static DualSenseDevice Device() return device; } + [DataTestMethod] + [DataRow(ConnectionType.BT, true, false, true, true)] + [DataRow(ConnectionType.BT, true, true, true, false)] + [DataRow(ConnectionType.BT, false, false, true, false)] + [DataRow(ConnectionType.USB, true, false, true, false)] + [DataRow(ConnectionType.BT, true, false, false, false)] + public void SpecialActionPreservesConnectionAndChargingGuards( + ConnectionType connection, bool synced, bool charging, bool optionsPressed, bool expected) + { + var device = new NoHidDevice(connection, synced, charging); + RunSpecialAction(device, 2, optionsPressed); + Assert.AreEqual(expected ? 1 : 0, device.DisconnectCalls); + if (expected) Assert.IsTrue(device.RequestedRemoval); + } + + private static void RunSpecialAction(DS4Device device, int slot, bool optionsPressed = true) + { + // Exercise the real action dispatcher with isolated in-memory profiles. + // The selected action contains no awaits, file writes or native input. + var storeField = typeof(Global).GetField("m_Config", BindingFlags.Static | BindingFlags.NonPublic)!; + BackingStore previousStore = Global.store; + var previousActions = Mapping.actionDone; + try + { + storeField.SetValue(null, new BackingStore()); + Mapping.actionDone = new(); + var action = new SpecialAction("Synthetic disconnect", "PS/Options", "DisconnectBT", "0"); + Global.GetActions().Add(action); + Global.store.profileActions[slot].Add(action.name); + Global.CalculateProfileActionDicts(slot); + var control = (ControlService)RuntimeHelpers.GetUninitializedObject(typeof(ControlService)); + control.DS4Controllers = new DS4Device[Global.MAX_DS4_CONTROLLER_COUNT]; + control.DS4Controllers[slot] = device; + var state = new DS4State { PS = true, Options = optionsPressed }; + var exposed = new DS4StateExposed(state); + var fields = new DS4StateFieldMapping(); + fields.PopulateFieldMapping(state, exposed, null); + typeof(Mapping).GetMethod("MapCustomAction", BindingFlags.Static | BindingFlags.NonPublic)! + .Invoke(null, new object[] { slot, state, new DS4State(), exposed, null, + control, fields, new DS4StateFieldMapping() }); + } + finally + { + Mapping.actionDone = previousActions; + storeField.SetValue(null, previousStore); + } + } + + private sealed class NoHidDevice : DS4Device + { + internal int DisconnectCalls; + internal bool RequestedRemoval; + internal NoHidDevice(ConnectionType connection, bool synced, bool charging) + : base("Synthetic action disconnect", InputDeviceType.DualSense, connection) + { + Synced = synced; + this.charging = charging; + } + public override bool DisconnectBT(bool callRemoval = false) + { + DisconnectCalls++; + RequestedRemoval = callRemoval; + return true; + } + } + private static void ClickTrayDisconnect(DS4Device device) { // Execute the actual tray handler without constructing a ControlService, From 12dae415ca5ed2c8fb4457d8617ab326692c23a2 Mon Sep 17 00:00:00 2001 From: petemess95 Date: Tue, 6 Oct 2026 10:44:47 -0400 Subject: [PATCH 5/6] Reuse one XmlSerializer for profile load and save XmlSerializer built with XmlAttributeOverrides is not cached by .NET. Every profile load (TryPrepare) and save (SaveProfileNew) built a new one, which generated and loaded a new dynamic assembly (~100 ms with its first use) that never unloads. Temporary profile switches pay this on every press and every release. Share one lazily built, thread-safe instance (ProfileDTO.Serializer). Warm TryPrepare: ~101 ms -> ~0.9 ms; loaded assemblies +1 per call -> +0. Co-Authored-By: Claude Opus 5.5 --- DS4Windows/DS4Control/DTOXml/ProfileDTO.cs | 11 +++ DS4Windows/DS4Control/PreparedProfileLoad.cs | 3 +- DS4Windows/DS4Control/ScpUtil.cs | 3 +- .../ProfileSerializerCacheTests.cs | 83 +++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 DS4WindowsTests/ProfileSerializerCacheTests.cs diff --git a/DS4Windows/DS4Control/DTOXml/ProfileDTO.cs b/DS4Windows/DS4Control/DTOXml/ProfileDTO.cs index cc919dde..07066b81 100644 --- a/DS4Windows/DS4Control/DTOXml/ProfileDTO.cs +++ b/DS4Windows/DS4Control/DTOXml/ProfileDTO.cs @@ -3976,6 +3976,17 @@ public void PostProcessXml() //OutputContDevice +Done } + // The framework only caches XmlSerializer(Type) and XmlSerializer(Type, string). + // Constructing one with XmlAttributeOverrides generates and loads a new dynamic + // assembly every time (~100 ms with first use) and that assembly never unloads. + // Serialize/Deserialize are thread-safe, so one shared instance is used instead. + private static readonly Lazy serializer = + new Lazy( + () => new XmlSerializer(typeof(ProfileDTO), GetAttributeOverrides()), + System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); + + internal static XmlSerializer Serializer => serializer.Value; + public static XmlAttributeOverrides GetAttributeOverrides() { XmlAttributeOverrides xmlOverrides = new XmlAttributeOverrides(); diff --git a/DS4Windows/DS4Control/PreparedProfileLoad.cs b/DS4Windows/DS4Control/PreparedProfileLoad.cs index ada16c2f..5833ddae 100644 --- a/DS4Windows/DS4Control/PreparedProfileLoad.cs +++ b/DS4Windows/DS4Control/PreparedProfileLoad.cs @@ -85,8 +85,7 @@ internal static bool TryPrepare(string path, int device, } } - var serializer = new XmlSerializer(typeof(ProfileDTO), - ProfileDTO.GetAttributeOverrides()); + var serializer = ProfileDTO.Serializer; using var reader = new StringReader(xml); var candidate = serializer.Deserialize(reader) as ProfileDTO; if (candidate == null) diff --git a/DS4Windows/DS4Control/ScpUtil.cs b/DS4Windows/DS4Control/ScpUtil.cs index 65a8e17e..b105a6ed 100644 --- a/DS4Windows/DS4Control/ScpUtil.cs +++ b/DS4Windows/DS4Control/ScpUtil.cs @@ -5574,8 +5574,7 @@ public bool SaveProfileNew(int device, string proName) string path = Path.Combine(Global.appdatapath, "Profiles", $"{proName}{Global.XML_EXTENSION}"); string testStr = string.Empty; - XmlSerializer serializer = new XmlSerializer(typeof(ProfileDTO), - ProfileDTO.GetAttributeOverrides()); + XmlSerializer serializer = ProfileDTO.Serializer; using (Utf8StringWriter strWriter = new Utf8StringWriter()) { using XmlWriter xmlWriter = XmlWriter.Create(strWriter, diff --git a/DS4WindowsTests/ProfileSerializerCacheTests.cs b/DS4WindowsTests/ProfileSerializerCacheTests.cs new file mode 100644 index 00000000..f831624a --- /dev/null +++ b/DS4WindowsTests/ProfileSerializerCacheTests.cs @@ -0,0 +1,83 @@ +using System.Text; +using System.Xml; +using System.Xml.Serialization; +using DS4Windows; +using DS4Windows.DS4Control; +using DS4WinWPF.DS4Control.DTOXml; + +namespace DS4WindowsTests; + +/// +/// The ProfileDTO serializer uses XmlAttributeOverrides, which .NET does not +/// cache. These tests check it is built once and that profile preparation does +/// not load a new assembly per call. +/// +[TestClass] +[DoNotParallelize] +public class ProfileSerializerCacheTests +{ + private string folder; + + [TestInitialize] + public void Setup() + { + folder = Path.Combine(Path.GetTempPath(), $"ds4w-serializer-cache-{Guid.NewGuid():N}"); + Directory.CreateDirectory(folder); + } + + [TestCleanup] + public void Cleanup() + { + try { Directory.Delete(folder, true); } catch (IOException) { } + } + + [TestMethod] + public void Serializer_ReturnsSameInstance() + { + Assert.IsNotNull(ProfileDTO.Serializer); + Assert.AreSame(ProfileDTO.Serializer, ProfileDTO.Serializer); + } + + [TestMethod] + public void RepeatedTryPrepare_DoesNotGrowAssemblyCount() + { + string path = Path.Combine(folder, "Aim.xml"); + File.WriteAllText(path, CreateSavedProfileXml( + "custom", "0.72, 0.26, 1.00, 1.00"), Encoding.UTF8); + + void Prepare() + { + bool ok = PreparedProfileLoad.TryPrepare(path, 0, out _, out _, out string error); + Assert.IsTrue(ok, error); + } + + for (int i = 0; i < 3; i++) + Prepare(); + int before = AppDomain.CurrentDomain.GetAssemblies().Length; + for (int i = 0; i < 20; i++) + Prepare(); + int after = AppDomain.CurrentDomain.GetAssemblies().Length; + + Assert.AreEqual(before, after, "TryPrepare loaded new assemblies per call."); + } + + // Builds a profile the way BackingStore.SaveProfileNew writes one. + private static string CreateSavedProfileXml(string rsCurveMode, string rsCurveCustom) + { + var serializer = new XmlSerializer(typeof(ProfileDTO), ProfileDTO.GetAttributeOverrides()); + using var strWriter = new Utf8StringWriter(); + using (XmlWriter xmlWriter = XmlWriter.Create(strWriter, + new XmlWriterSettings { Encoding = Encoding.UTF8, Indent = true })) + { + xmlWriter.WriteComment(" DS4Windows Configuration Data. test "); + xmlWriter.WriteWhitespace("\r\n\r\n"); + var dto = new ProfileDTO { DeviceIndex = 0 }; + dto.MapFrom(new BackingStore()); + dto.RSOutputCurveMode = rsCurveMode; + dto.RSOutputCurveCustom = rsCurveCustom; + serializer.Serialize(xmlWriter, dto, + new XmlSerializerNamespaces(new[] { XmlQualifiedName.Empty })); + } + return strWriter.ToString(); + } +} From 91dfbf7070ff7eacd98b20f5305cb85b461dff7c Mon Sep 17 00:00:00 2001 From: Pawel Osmolski <48226562+Pav-Osmolski@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:05:06 +0100 Subject: [PATCH 6/6] Prepare 5.0.14 numeric Latest releases with approved unsigned policy --- .github/workflows/ci-build.yml | 4 ++ .github/workflows/release.yml | 70 ++++++++++++------ CHANGELOG.md | 31 ++++++-- DS4Windows/DS4WinWPF.csproj | 8 +-- DS4WindowsTests/ReleaseChannelPolicyTests.cs | 13 ++++ DS4WindowsTests/ReleaseSigningPolicyTests.cs | 59 ++++++++++++--- README.md | 2 +- docs/INSTALLER_VALIDATION_STRATEGY.md | 12 ++-- docs/getting-started.md | 9 ++- docs/release-process.md | 41 +++++++++++ installer/README.md | 21 +++--- installer/build-installer.ps1 | 4 +- utils/test-localization-package.py | 8 +-- utils/test-release-identity.ps1 | 76 ++++++++++++++++++++ utils/validate-installer.py | 28 +++++--- 15 files changed, 307 insertions(+), 79 deletions(-) create mode 100644 docs/release-process.md create mode 100644 utils/test-release-identity.ps1 diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 54b95470..d607d496 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -34,6 +34,10 @@ jobs: # the release gate. Hardware-dependent tests retain explicit opt-in gates. run: dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --verbosity normal + - name: Test numeric release identity and legacy publication policy + shell: pwsh + run: .\utils\test-release-identity.ps1 + - name: Test renamed executable setup process guards shell: powershell run: .\utils\test-renamed-executable-setup.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de65c177..32507754 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: tag: - description: Existing named VIIPERRC prerelease draft to build without publishing + description: Existing numeric release draft to build without publishing (for example 5.0.14) required: true type: string @@ -20,7 +20,7 @@ jobs: outputs: tag: ${{ steps.release.outputs.tag }} release_id: ${{ steps.release.outputs.release_id }} - unsigned_rc: ${{ steps.release.outputs.unsigned_rc }} + unsigned_release: ${{ steps.release.outputs.unsigned_release }} verify_existing: ${{ steps.release.outputs.verify_existing }} steps: - name: Verify release identity and signing policy @@ -66,8 +66,13 @@ jobs: $isPrerelease = $release.prerelease.ToString().ToLowerInvariant() $unsignedRc = $isPrerelease -ceq 'true' -and $tag -cmatch '^VIIPERRC[0-9]+(\.[0-9]+){0,3}\z' - if ($dispatch -and -not $unsignedRc) { - throw 'Draft dispatch is limited to named RC prereleases; other releases retain the signed published-event path.' + $numericStable = $env:GITHUB_REPOSITORY -ceq 'Pav-Osmolski/DS4Windows' -and + $isPrerelease -ceq 'false' -and + $tag -cmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z' -and + ([version]$tag) -ge ([version]'5.0.14') + $unsignedRelease = $unsignedRc -or $numericStable + if ($dispatch -and -not $numericStable) { + throw 'New draft builds require a numeric non-prerelease tag of 5.0.14 or newer in this fork.' } if ($dispatch) { # Saving a new draft does not create its Git tag. Materialize only @@ -82,14 +87,21 @@ jobs: gh api --method POST "repos/$env:GITHUB_REPOSITORY/git/refs" ` -f "ref=refs/tags/$tag" -f "sha=$($target.sha)" } + $tagged = gh api "repos/$env:GITHUB_REPOSITORY/commits/$tag" | ConvertFrom-Json + if ($tagged.sha -cne $env:GITHUB_SHA) { + throw 'Draft builds require the tag and selected workflow commit to match.' + } } "tag=$tag" >> $env:GITHUB_OUTPUT "release_id=$($release.id)" >> $env:GITHUB_OUTPUT - "unsigned_rc=$($unsignedRc.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT - "verify_existing=$(((-not $dispatch) -and $unsignedRc).ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "unsigned_release=$($unsignedRelease.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "verify_existing=$(((-not $dispatch) -and $unsignedRelease).ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT if ($unsignedRc) { Write-Warning 'Unsigned named release candidate; not a signed stable release.' 'Unsigned named release candidate; Authenticode signing intentionally omitted.' >> $env:GITHUB_STEP_SUMMARY + } elseif ($numericStable) { + Write-Warning 'Unsigned numeric release; no Authenticode signature is claimed.' + 'Unsigned numeric release; exact source and asset integrity checks remain mandatory.' >> $env:GITHUB_STEP_SUMMARY } else { Write-Host 'Approved certificate, identity and timestamp gates remain mandatory.' } @@ -99,10 +111,10 @@ jobs: runs-on: windows-2022 env: RELEASE_TAG: ${{ needs.identity.outputs.tag }} - UNSIGNED_RC_RELEASE: ${{ needs.identity.outputs.unsigned_rc }} + UNSIGNED_RELEASE: ${{ needs.identity.outputs.unsigned_release }} RELEASE_ID: ${{ needs.identity.outputs.release_id }} - # Release publication follows independently checked CI. Named RC prereleases - # may be unsigned; stable and all other releases still require signing. + # Build and test a draft before publication. This fork permits unsigned + # numeric Latest releases; other release types retain signing gates. steps: - uses: actions/checkout@v4 with: @@ -129,6 +141,9 @@ jobs: PROJECT_VERSION=$(python -c "import xml.etree.ElementTree as ET; print(ET.parse('DS4Windows/DS4WinWPF.csproj').find('.//Version').text)") if [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}(-.*)?$ ]]; then BINARY_VERSION=${VERSION%%-*} + if [[ "$BINARY_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + BINARY_VERSION="$BINARY_VERSION.0" + fi PACKAGE_VERSION=$VERSION else BINARY_VERSION=$PROJECT_ASSEMBLY_VERSION @@ -150,7 +165,7 @@ jobs: "-p:InformationalVersion=$env:VERSION" ` -o .\bin\x64\Release\output - name: Sign and verify release binaries - if: env.UNSIGNED_RC_RELEASE != 'true' + if: env.UNSIGNED_RELEASE != 'true' shell: pwsh env: DS4W_SIGN_CERT_BASE64: ${{ secrets.DS4W_SIGN_CERT_BASE64 }} @@ -196,8 +211,8 @@ jobs: - name: Build standard installer X64 shell: pwsh env: - DS4W_SIGN_CERT_PASSWORD: ${{ env.UNSIGNED_RC_RELEASE != 'true' && secrets.DS4W_SIGN_CERT_PASSWORD || '' }} - DS4W_SIGN_EXPECTED_THUMBPRINT: ${{ env.UNSIGNED_RC_RELEASE != 'true' && secrets.DS4W_SIGN_EXPECTED_THUMBPRINT || '' }} + DS4W_SIGN_CERT_PASSWORD: ${{ env.UNSIGNED_RELEASE != 'true' && secrets.DS4W_SIGN_CERT_PASSWORD || '' }} + DS4W_SIGN_EXPECTED_THUMBPRINT: ${{ env.UNSIGNED_RELEASE != 'true' && secrets.DS4W_SIGN_EXPECTED_THUMBPRINT || '' }} run: | $parameters = @{ PublishRoot = ".\bin\x64\Release\DS4Windows" @@ -206,7 +221,7 @@ jobs: DisplayVersion = $env:VERSION OutputDirectory = ".\bin\x64\Release\installer" SkipApplicationPublish = $true - RequireSigning = $env:UNSIGNED_RC_RELEASE -ne 'true' + RequireSigning = $env:UNSIGNED_RELEASE -ne 'true' } .\installer\build-installer.ps1 @parameters - name: Name release asset @@ -286,7 +301,7 @@ jobs: $noticeNames = @('VIIPER-0.1.9-rc4.6.6-LICENSES.txt', 'VIIPER-0.1.9-rc4.6.6-PROVENANCE.txt', 'VIIPER-0.1.9-rc4.6.6-BUILD-NOTES.txt', 'VIIPER-SYSTRAY-LICENSE.txt', 'VIIPER-SYSTRAY-NOTICE.md', 'VIIPER-WDL-NOTICE.txt', 'LICENSE.txt') foreach ($name in $noticeNames) { Copy-Item -LiteralPath (Join-Path $publish "extras\$name") -Destination $records } - $signing = if ($env:UNSIGNED_RC_RELEASE -ceq 'true') { 'Unsigned named release candidate' } else { 'Required Authenticode signing verified' } + $signing = if ($env:UNSIGNED_RELEASE -ceq 'true') { 'Unsigned release (no Authenticode signature)' } else { 'Required Authenticode signing verified' } @("DS4Windows release: $env:RELEASE_TAG", "DS4Windows source commit: $sourceCommit", "VIIPER source commit: $brokerCommit", "VIIPER binary SHA-256: $brokerHash", "Signing: $signing", "GitHub Actions run: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID") | @@ -300,7 +315,8 @@ jobs: $receipt = @{ schema = 1; repository = $env:GITHUB_REPOSITORY; tag = $env:RELEASE_TAG sourceCommit = $sourceCommit; releaseId = $env:RELEASE_ID; runId = $env:GITHUB_RUN_ID - unsignedRc = ($env:UNSIGNED_RC_RELEASE -ceq 'true'); binaryVersion = $env:BINARY_VERSION + unsignedRc = ($env:RELEASE_TAG -cmatch '^VIIPERRC'); unsignedRelease = ($env:UNSIGNED_RELEASE -ceq 'true') + prerelease = ($env:RELEASE_TAG -cmatch '^VIIPERRC'); binaryVersion = $env:BINARY_VERSION brokerSourceName = $brokerSourceName; brokerCommit = $brokerCommit assets = @($assets | ForEach-Object { @{ name = [IO.Path]::GetFileName($_); sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash } }) } @@ -363,12 +379,12 @@ jobs: if (Test-Path -LiteralPath $certificatePath -PathType Leaf) { Remove-Item -LiteralPath $certificatePath -Force } - verify_published_rc: + verify_published_release: needs: identity if: needs.identity.outputs.verify_existing == 'true' runs-on: windows-2022 steps: - - name: Verify published RC bytes from the successful draft build + - name: Verify published release bytes from the successful draft build shell: pwsh env: GITHUB_TOKEN: ${{ github.TOKEN }} @@ -378,7 +394,7 @@ jobs: $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $release = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" | ConvertFrom-Json - $records = Join-Path $env:RUNNER_TEMP 'verify-published-rc' + $records = Join-Path $env:RUNNER_TEMP 'verify-published-release' New-Item -ItemType Directory -Path $records | Out-Null $receiptAsset = @($release.assets | Where-Object { $_.name -ceq 'RELEASE-BUILD.json' }) if ($receiptAsset.Count -ne 1 -or $receiptAsset[0].uploader.login -cne 'github-actions[bot]') { @@ -389,7 +405,8 @@ jobs: $tagCommit = (gh api "repos/$env:GITHUB_REPOSITORY/commits/$env:RELEASE_TAG" --jq '.sha').Trim() if ($receipt.schema -ne 1 -or $receipt.repository -cne $env:GITHUB_REPOSITORY -or $receipt.tag -cne $env:RELEASE_TAG -or "$($receipt.releaseId)" -cne $env:RELEASE_ID -or - $receipt.sourceCommit -cne $tagCommit -or $receipt.unsignedRc -ne $true -or + $receipt.sourceCommit -cne $tagCommit -or + (-not $receipt.unsignedRc -and ($receipt.unsignedRelease -ne $true -or $receipt.prerelease -ne $false)) -or $receipt.brokerSourceName -cnotmatch '^[0-9A-Za-z][0-9A-Za-z._-]+-SOURCE\.zip\z' -or "$($receipt.runId)" -cnotmatch '^[0-9]+\z') { throw 'RC receipt identity mismatch.' } $run = gh api "repos/$env:GITHUB_REPOSITORY/actions/runs/$($receipt.runId)" | ConvertFrom-Json @@ -407,10 +424,21 @@ jobs: throw "Published RC asset hash mismatch: $($asset.name)" } } - foreach ($required in @('DS4Windows_VIIPER_x64.zip', "DS4Windows_$($receipt.binaryVersion)_Setup_x64.exe", + $portableName = if ($receipt.unsignedRc) { 'DS4Windows_VIIPER_x64.zip' } else { "DS4Windows_$($env:RELEASE_TAG)_x64.zip" } + if (-not $receipt.unsignedRc -and ($release.prerelease -or $receipt.binaryVersion -cne "$($env:RELEASE_TAG).0")) { + throw 'Numeric release channel or Windows version mismatch.' + } + foreach ($required in @($portableName, "DS4Windows_$($receipt.binaryVersion)_Setup_x64.exe", "DS4Windows-$($env:RELEASE_TAG)-SOURCE.zip", $receipt.brokerSourceName, 'SHA256SUMS.txt', 'SOURCE-REVISIONS.txt', 'VIIPER-0.1.9-rc4.6.6-LICENSES.txt', 'VIIPER-0.1.9-rc4.6.6-PROVENANCE.txt', 'VIIPER-0.1.9-rc4.6.6-BUILD-NOTES.txt', 'VIIPER-SYSTRAY-LICENSE.txt', 'VIIPER-SYSTRAY-NOTICE.md', 'VIIPER-WDL-NOTICE.txt', 'LICENSE.txt')) { if (-not $seen.Contains($required)) { throw "Incomplete RC receipt: $required" } } - Write-Host 'Published unsigned RC matches its successful draft build; no assets rebuilt or overwritten.' + Write-Host 'Published release matches its successful draft build; no assets rebuilt or overwritten.' + if (-not $receipt.unsignedRc) { + gh api --method PATCH "repos/$env:GITHUB_REPOSITORY/releases/$env:RELEASE_ID" -F prerelease=false -f make_latest=true + $latest = gh api "repos/$env:GITHUB_REPOSITORY/releases/latest" | ConvertFrom-Json + if ("$($latest.id)" -cne $env:RELEASE_ID -or $latest.prerelease -or $latest.draft) { + throw 'Published numeric release was not selected as Latest.' + } + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa12cf0..1e924a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,31 @@ Release history for the Pav-Osmolski DS4Windows fork. Downloads, source archives, checksums, and build records are available on [Releases](https://github.com/Pav-Osmolski/DS4Windows/releases). -## Unreleased — VIIPERRC4.6.8 +## Unreleased — 5.0.14 + +### Fixed + +- Preserve the previous profile when a save is interrupted ([upstream #129](https://github.com/hbashton/DS4Windows/pull/129), meiameiameia). +- Keep virtual output slots registered when disconnect fails, allowing a retry without reusing the occupied slot ([upstream #130](https://github.com/hbashton/DS4Windows/pull/130), meiameiameia). +- Respect touchpad-click remapping and passthrough settings in emulated DS4, DualSense and DualSense Edge reports ([upstream #131](https://github.com/hbashton/DS4Windows/pull/131), meiameiameia). +- Remove the controller properly after a Bluetooth disconnect special action, allowing reconnection without Stop/Start ([upstream #132](https://github.com/hbashton/DS4Windows/pull/132), meiameiameia). +- Reuse the profile XML serializer to avoid repeatedly generating assemblies and reduce profile-switch overhead ([upstream #134](https://github.com/hbashton/DS4Windows/pull/134), petemess95). + +### Changed + +- Retired RC release names. New releases use numeric versions and are published as **Latest**, with the prerelease flag cleared. +- App and installer versions advance to **5.0.14.0**; release and package version **5.0.14**. Installer: `DS4Windows_5.0.14.0_Setup_x64.exe`; portable package: `DS4Windows_5.0.14_x64.zip`. +- Continue unsigned releases with exact-source build records and checksum verification. See the [release process](docs/release-process.md). + +### Upgrade notes + +DS4Updater 2.0.9 already supports numeric releases and the schema 1 build +receipt. Existing fork builds with fork update support can update to 5.0.14. +RC4.6.7 users still need one manual upgrade because that build checks upstream. +The pinned VIIPER and USB/IP packages are unchanged. Every new release must +increment the numeric app and installer version. + +## VIIPERRC4.6.8 — tested build ### Changed @@ -20,9 +44,8 @@ manually once. Replacing DS4Updater alone does not change RC4.6.7's upstream update checks. Later updates from the new app use this fork. DS4Updater [2.0.9](https://github.com/Pav-Osmolski/DS4Updater/releases/tag/v2.0.9) -is published. RC4.6.8 remains a draft for testing. The pinned VIIPER and USB/IP -packages are unchanged from RC4.6.7. Each new RC must increment the numeric -app and installer version. +is published. RC4.6.8 was tested successfully. Its changes are included in +5.0.14. The pinned VIIPER and USB/IP packages are unchanged from RC4.6.7. ## [VIIPERRC4.6.7](https://github.com/Pav-Osmolski/DS4Windows/releases/tag/VIIPERRC4.6.7) — 2026-10-09 diff --git a/DS4Windows/DS4WinWPF.csproj b/DS4Windows/DS4WinWPF.csproj index 23624867..6a573b5b 100644 --- a/DS4Windows/DS4WinWPF.csproj +++ b/DS4Windows/DS4WinWPF.csproj @@ -22,10 +22,10 @@ a SustainedLowLatency lease in the process. --> false true - 5.0.13.0 - 5.0.13.0 - 5.0.13.0-rc4.6.8 - VIIPERRC4.6.8 + 5.0.14.0 + 5.0.14.0 + 5.0.14 + 5.0.14 false Debug x64 diff --git a/DS4WindowsTests/ReleaseChannelPolicyTests.cs b/DS4WindowsTests/ReleaseChannelPolicyTests.cs index 093502fa..73e20b2c 100644 --- a/DS4WindowsTests/ReleaseChannelPolicyTests.cs +++ b/DS4WindowsTests/ReleaseChannelPolicyTests.cs @@ -34,6 +34,19 @@ public void PrereleaseBuildFollowsNewestPrereleaseWhenItIsNewer() selected, "5.0.0.0", true, installedReleaseTag: null)); } + [DataTestMethod] + [DataRow("VIIPERRC4.6.7", "5.0.12.0")] + [DataRow("VIIPERRC4.6.8", "5.0.13.0")] + public void OldForkRcBuildCanMoveToNumericLatestRelease(string installedTag, string installedVersion) + { + GithubRelease selected = ReleaseChannelPolicy.SelectPreferredRelease( + Releases(Stable("5.0.14", "2026-10-10T00:00:00Z"), + Prerelease(installedTag, "2026-10-09T00:00:00Z")), true); + Assert.AreEqual("5.0.14", selected.TagName); + Assert.IsTrue(ReleaseChannelPolicy.ShouldUpdate(selected, installedVersion, true, installedTag)); + Assert.IsFalse(ReleaseChannelPolicy.ShouldUpdate(selected, "5.0.14.0", false, "5.0.14")); + } + [TestMethod] public void NewerStableReleaseWinsForPrereleaseBuild() { diff --git a/DS4WindowsTests/ReleaseSigningPolicyTests.cs b/DS4WindowsTests/ReleaseSigningPolicyTests.cs index de74b4de..3cf010be 100644 --- a/DS4WindowsTests/ReleaseSigningPolicyTests.cs +++ b/DS4WindowsTests/ReleaseSigningPolicyTests.cs @@ -50,36 +50,36 @@ public void IdentityComesFromSameRepositoryApiAndDispatchRequiresDraft() StringAssert.Contains(source, "$env:EVENT_RELEASE_ID"); StringAssert.Contains(source, "$release.prerelease.ToString().ToLowerInvariant() -cne $env:EVENT_PRERELEASE"); StringAssert.Contains(source, "$isPrerelease = $release.prerelease.ToString().ToLowerInvariant()"); - StringAssert.Contains(source, "UNSIGNED_RC_RELEASE: ${{ needs.identity.outputs.unsigned_rc }}"); + StringAssert.Contains(source, "UNSIGNED_RELEASE: ${{ needs.identity.outputs.unsigned_release }}"); Assert.IsFalse(source.Contains("github.event.release.body", StringComparison.Ordinal)); } [TestMethod] - public void DraftDispatchRejectsStableAndUnknownReleaseTypesBeforeExposingOutputs() + public void DraftDispatchRequiresNumericForkReleaseBeforeExposingOutputs() { string source = Workflow(); - int gate = source.IndexOf("if ($dispatch -and -not $unsignedRc)", StringComparison.Ordinal); + int gate = source.IndexOf("if ($dispatch -and -not $numericStable)", StringComparison.Ordinal); int output = source.IndexOf("\"tag=$tag\" >> $env:GITHUB_OUTPUT", StringComparison.Ordinal); Assert.IsTrue(gate >= 0 && gate < output); - StringAssert.Contains(source[gate..output], "throw 'Draft dispatch is limited to named RC prereleases;"); + StringAssert.Contains(source[gate..output], "throw 'New draft builds require a numeric non-prerelease tag of 5.0.14 or newer in this fork."); StringAssert.Contains(source, "if ($dispatch -and -not $release.draft)"); - StringAssert.Contains(source, "RequireSigning = $env:UNSIGNED_RC_RELEASE -ne 'true'"); + StringAssert.Contains(source, "RequireSigning = $env:UNSIGNED_RELEASE -ne 'true'"); } [TestMethod] - public void StableAndUnknownPrereleasesRetainCertificateIdentityAndInstallerSigningGates() + public void OtherReleaseTypesRetainCertificateIdentityAndInstallerSigningGates() { string source = Workflow(); string signing = Between(source, " - name: Sign and verify release binaries", " - name: Post-Build script X64"); - StringAssert.Contains(signing, "if: env.UNSIGNED_RC_RELEASE != 'true'"); + StringAssert.Contains(signing, "if: env.UNSIGNED_RELEASE != 'true'"); foreach (string secret in new[] { "DS4W_SIGN_CERT_BASE64", "DS4W_SIGN_CERT_PASSWORD", "DS4W_SIGN_EXPECTED_THUMBPRINT" }) StringAssert.Contains(signing, "${{ secrets." + secret + " }}"); StringAssert.Contains(signing, "'^[0-9A-Fa-f]{40}$'"); StringAssert.Contains(signing, "$signature.Status -ne \"Valid\""); StringAssert.Contains(signing, "$signature.SignerCertificate.Thumbprint -ne $approvedThumbprint"); StringAssert.Contains(signing, "-not $signature.TimeStamperCertificate"); - StringAssert.Contains(source, "RequireSigning = $env:UNSIGNED_RC_RELEASE -ne 'true'"); - StringAssert.Contains(source, "env.UNSIGNED_RC_RELEASE != 'true' && secrets.DS4W_SIGN_CERT_PASSWORD || ''"); + StringAssert.Contains(source, "RequireSigning = $env:UNSIGNED_RELEASE -ne 'true'"); + StringAssert.Contains(source, "env.UNSIGNED_RELEASE != 'true' && secrets.DS4W_SIGN_CERT_PASSWORD || ''"); StringAssert.Contains(source, "Unsigned named release candidate; not a signed stable release."); } @@ -109,11 +109,11 @@ public void UploadRequiresCurrentAssetHashesCorrespondingSourceAndNoOverwrite() } [TestMethod] - public void PublishedRcVerifiesSuccessfulDraftRunAndActualBytesWithoutRebuild() + public void PublishedReleaseVerifiesSuccessfulDraftRunAndActualBytesWithoutRebuild() { string source = Workflow(); StringAssert.Contains(source, "if: needs.identity.outputs.verify_existing != 'true'"); - string verify = source[source.IndexOf(" verify_published_rc:", StringComparison.Ordinal)..]; + string verify = source[source.IndexOf(" verify_published_release:", StringComparison.Ordinal)..]; StringAssert.Contains(verify, "if: needs.identity.outputs.verify_existing == 'true'"); StringAssert.Contains(verify, "$receipt.sourceCommit -cne $tagCommit"); StringAssert.Contains(verify, "$run.event -cne 'workflow_dispatch'"); @@ -126,6 +126,43 @@ public void PublishedRcVerifiesSuccessfulDraftRunAndActualBytesWithoutRebuild() Assert.IsFalse(verify.Contains("gh release upload", StringComparison.Ordinal)); } + [DataTestMethod] + [DataRow("false", "5.0.14", true)] + [DataRow("false", "5.0.15", true)] + [DataRow("false", "6.0.0", true)] + [DataRow("true", "5.0.14", false)] + [DataRow("false", "5.0.13", false)] + [DataRow("false", "5.0.14.0", false)] + [DataRow("false", "v5.0.14", false)] + [DataRow("false", "05.0.14", false)] + [DataRow("false", "5.0.14-rc1", false)] + [DataRow("false", "5.0.14\n", false)] + public void NumericLatestPolicyUsesCanonicalVersionsAndNormalReleaseFlag(string flag, string tag, bool expected) + { + string source = Workflow(); + string numeric = source[source.IndexOf("$numericStable =", StringComparison.Ordinal)..]; + Match pattern = Regex.Match(numeric, @"\$tag -cmatch '(?[^']+)'"); + Assert.IsTrue(pattern.Success); + bool accepted = flag == "false" && Regex.IsMatch(tag, pattern.Groups["pattern"].Value) && + Version.TryParse(tag, out Version version) && version >= new Version(5, 0, 14); + Assert.AreEqual(expected, accepted); + StringAssert.Contains(numeric, "$env:GITHUB_REPOSITORY -ceq 'Pav-Osmolski/DS4Windows'"); + StringAssert.Contains(numeric, "$isPrerelease -ceq 'false'"); + } + + [TestMethod] + public void NumericReleasesKeepUpdaterReceiptCompatibilityAndSelectLatestAfterVerification() + { + string source = Workflow(); + StringAssert.Contains(source, "schema = 1;"); + StringAssert.Contains(source, "BINARY_VERSION=\"$BINARY_VERSION.0\""); + StringAssert.Contains(source, "if ($tagged.sha -cne $env:GITHUB_SHA)"); + int verify = source.IndexOf("Hash -cne $asset.sha256", StringComparison.Ordinal); + int latest = source.IndexOf("-F prerelease=false -f make_latest=true", StringComparison.Ordinal); + Assert.IsTrue(verify >= 0 && latest > verify); + StringAssert.Contains(source, "$latest.id)\" -cne $env:RELEASE_ID -or $latest.prerelease -or $latest.draft"); + } + private static string Between(string source, string start, string end) { int first = source.IndexOf(start, StringComparison.Ordinal); diff --git a/README.md b/README.md index 969a71b2..e7fab465 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Controller remapping, advanced feedback, and per-game profiles for Windows. [**Download**](https://github.com/Pav-Osmolski/DS4Windows/releases) · [Getting started](docs/getting-started.md) · [Changelog](CHANGELOG.md) · [Community](https://www.reddit.com/r/DS4Windows/) -[![Releases](https://img.shields.io/github/v/release/Pav-Osmolski/DS4Windows?include_prereleases&logo=github&label=release)](https://github.com/Pav-Osmolski/DS4Windows/releases) +[![Releases](https://img.shields.io/github/v/release/Pav-Osmolski/DS4Windows?logo=github&label=release)](https://github.com/Pav-Osmolski/DS4Windows/releases) [![Main build](https://github.com/Pav-Osmolski/DS4Windows/actions/workflows/ci-build.yml/badge.svg?branch=main)](https://github.com/Pav-Osmolski/DS4Windows/actions/workflows/ci-build.yml?query=branch%3Amain) DS4Windows overview with a connected controller and profile controls diff --git a/docs/INSTALLER_VALIDATION_STRATEGY.md b/docs/INSTALLER_VALIDATION_STRATEGY.md index 3015011e..887b2934 100644 --- a/docs/INSTALLER_VALIDATION_STRATEGY.md +++ b/docs/INSTALLER_VALIDATION_STRATEGY.md @@ -115,12 +115,12 @@ build host. uninstall, downgrade, cancellation, concurrency, failure, and reboot/resume. - Atomic publication of the completed installer only after every gate passes. The verified manifest is committed first and the installer EXE last. -- Mandatory Authenticode verification for stable and other signed release - artifacts; their composition is blocked when signing material or a - first-party signature is missing. Only an API-verified named `VIIPERRC` - prerelease may use the explicitly unsigned, draft-first - [release policy](validation/2026-09-09-release-publication-policy.md); - it retains the other package integrity gates. +- Mandatory Authenticode verification for signed release artifacts; their + composition is blocked when signing material or a first-party signature + is missing. This fork explicitly permits unsigned numeric Latest releases + from `5.0.14` onward, retaining exact source and package integrity gates + through the [draft-first release process](release-process.md). Historical + named `VIIPERRC` prerelease receipts remain verifiable. - WiX Burn's two-part signature sequence: detach/sign the cached engine, reattach it, then sign the whole bundle. Composition verifies the detached engine's signature before reattachment, then extracts/hash-checks the final diff --git a/docs/getting-started.md b/docs/getting-started.md index 937cc097..779d1a97 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,10 +17,9 @@ The Visual C++ runtime remains a separate system prerequisite. ## Install Download only from [this repository's Releases page](https://github.com/Pav-Osmolski/DS4Windows/releases). -Current DS4Windows 5 builds are release candidates; check the notes for -limitations and signing status. -The current release, [VIIPERRC4.6.7](https://github.com/Pav-Osmolski/DS4Windows/releases/tag/VIIPERRC4.6.7), -is unsigned. +Starting with **5.0.14**, new releases use numeric versions and are marked +**Latest**. Check the release notes for limitations and signing status; +this fork currently publishes unsigned builds. ### Recommended: all-in-one installer @@ -36,7 +35,7 @@ avoid independently upgrading or downgrading USB/IP for this installation. ### Portable ZIP -1. Extract **all** of `DS4Windows_VIIPER_x64.zip` into a permanent, writable folder. +1. Extract **all** of the release's `DS4Windows__x64.zip` (older RCs use `DS4Windows_VIIPER_x64.zip`) into a permanent, writable folder. Do not launch from inside the ZIP or copy only `DS4Windows.exe`. Keep the `Lang` folder with the application; it contains the translations. 2. Run `DS4Windows.exe` from that folder. On a new PC, complete driver setup diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 00000000..bc731805 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,41 @@ +# Release process + +New releases in this fork use numeric tags, starting with **5.0.14**. They +are normal GitHub releases with **Latest** selected and **Set as a +pre-release** cleared. The former `VIIPERRC4.x.x` names are historical. + +For each release, advance all of these together: + +- Release tag, package and informational version: `5.0.14`. +- Assembly, file, MSI product and installer bundle version: `5.0.14.0`. +- Installer: `DS4Windows_5.0.14.0_Setup_x64.exe`. +- Portable package: `DS4Windows_5.0.14_x64.zip`. + +Every subsequent release increments the numeric version. Keep change lists +and upgrade notes in [CHANGELOG.md](../CHANGELOG.md); the README stays concise. + +1. Merge reviewed changes and wait for the `main` CI tests, package checks and + installer lifecycle/upgrade checks to pass. +2. Create a draft targeting that exact `main` commit. Use a three-part numeric + tag such as `5.0.14`, clear the prerelease flag, and link the release body to + the tagged `CHANGELOG.md`. +3. Run **.NET Release** from that same commit/branch, entering the draft tag. + The workflow creates a missing tag only when the draft target matches the + selected commit. It builds the portable ZIP, installer, matching source + archives, notices, checksums and `RELEASE-BUILD.json` into the draft. +4. Test those downloads. Existing release assets are never overwritten. +5. Publish the tested draft with **Latest** selected. The publication workflow + verifies the successful draft run, source identity and every recorded asset + hash without rebuilding, then explicitly selects that release as Latest. + +These numeric releases are currently **unsigned**. Latest describes the +GitHub release channel; it does not claim an Authenticode signature. The +maintainer has approved unsigned numeric releases for this fork while trusted +signing is investigated separately. Include the signing status in release +notes. Source, digest, dependency and installer validation remain mandatory. +Other release types retain the certificate, signer and timestamp checks. + +The receipt remains schema 1 for compatibility with DS4Updater 2.0.9. +Historical named RC receipts remain verifiable. Numeric releases carry a +four-part Windows version and a three-part release marker, so existing fork +builds can move from the RC channel to a newer normal release. diff --git a/installer/README.md b/installer/README.md index ba4a26f5..19c601c5 100644 --- a/installer/README.md +++ b/installer/README.md @@ -22,9 +22,9 @@ portable repair stays in the portable folder. ```powershell .\installer\build-installer.ps1 ` -PublishRoot .\bin\x64\Release\output ` - -ProductVersion 5.0.13.0 ` - -BundleVersion 5.0.13.0 ` - -DisplayVersion VIIPERRC4.6.8 ` + -ProductVersion 5.0.14.0 ` + -BundleVersion 5.0.14.0 ` + -DisplayVersion 5.0.14 ` -SkipApplicationPublish ``` @@ -53,7 +53,7 @@ log directory's `task-backups` folder. Set `DS4W_SIGN_CERT_PATH` plus `DS4W_SIGN_CERT_PASSWORD`, or use `DS4W_SIGN_CERT_THUMBPRINT` for a protected certificate-store identity. Set `DS4W_SIGN_EXPECTED_THUMBPRINT` to the independently approved signer and, -optionally, set `DS4W_SIGN_TIMESTAMP_URL`. Stable and other signed release +optionally, set `DS4W_SIGN_TIMESTAMP_URL`. Signed release builds pass `-RequireSigning`; they fail closed unless the first-party DS4Windows application, setup hosts, MSI, and final EXE have that valid timestamped signature. The bundled upstream VIIPER executable remains byte-identical and @@ -61,12 +61,13 @@ unsigned; its fixed SHA-256 and complete source/build provenance are validated instead. Signed GitHub release jobs require the `DS4W_SIGN_CERT_BASE64`, `DS4W_SIGN_CERT_PASSWORD`, and `DS4W_SIGN_EXPECTED_THUMBPRINT` secrets. -The user-authorized exception is an actual GitHub prerelease with an exact -named `VIIPERRC` ordinal tag. Its draft-first workflow explicitly records an -unsigned build, retains the other integrity gates, and verifies those same -assets after publication without rebuilding them. Numeric prereleases, -unknown tags and stable releases cannot use that exception. See the -[publication policy](../docs/validation/2026-09-09-release-publication-policy.md). +This fork uses numeric release tags starting at `5.0.14`, published as +Latest with the prerelease flag cleared. These releases remain unsigned; +the workflow records that status explicitly and retains exact source, +package hash, pinned dependency and installer validation. Build the draft +first, test it, then publish the same bytes. See the +[current release process](../docs/release-process.md). Historical named RC +receipts remain verifiable. Other release types still require signing. Signed Burn bundles use WiX's required two-part flow: detach and sign the cached engine, reattach that engine to the original bundle, then sign the diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 index 36e84917..4efc6882 100644 --- a/installer/build-installer.ps1 +++ b/installer/build-installer.ps1 @@ -2,8 +2,8 @@ param( [Parameter(Mandatory = $true)] [string]$PublishRoot, - [string]$ProductVersion = "5.0.13.0", - [string]$DisplayVersion = "VIIPERRC4.6.8", + [string]$ProductVersion = "5.0.14.0", + [string]$DisplayVersion = "5.0.14", [string]$BundleVersion, [string]$OutputDirectory, [switch]$SkipApplicationPublish, diff --git a/utils/test-localization-package.py b/utils/test-localization-package.py index 5e7e7de1..f30eb984 100644 --- a/utils/test-localization-package.py +++ b/utils/test-localization-package.py @@ -441,7 +441,7 @@ def test_installer_uses_the_validated_policy_entrypoint(self): def test_release_identity_cannot_bypass_the_verified_job(self): self.assert_policy_mutations_rejected([ ('RELEASE_TAG: ${{ needs.identity.outputs.tag }}', 'RELEASE_TAG: ${{ github.event.release.tag_name }}'), - ('UNSIGNED_RC_RELEASE: ${{ needs.identity.outputs.unsigned_rc }}', 'UNSIGNED_RC_RELEASE: true'), + ('UNSIGNED_RELEASE: ${{ needs.identity.outputs.unsigned_release }}', 'UNSIGNED_RELEASE: true'), ('$release.tag_name -cne $tag -or $release.id -le 0', '$false'), ('$dispatch -and -not $release.draft', '$false'), ('gh api --paginate --slurp', 'gh api'), @@ -454,13 +454,13 @@ def test_unsigned_exception_cannot_expand_to_stable_or_unknown_tags(self): self.assert_policy_mutations_rejected([ ("$unsignedRc = $isPrerelease -ceq 'true' -and", '$unsignedRc = $true -and'), ("$tag -cmatch '^VIIPERRC[0-9]+(\\.[0-9]+){0,3}\\z'", "$tag -match '^VIIPER'"), - ('if ($dispatch -and -not $unsignedRc)', 'if ($false)'), - ("RequireSigning = $env:UNSIGNED_RC_RELEASE -ne 'true'", 'RequireSigning = $false'), + ('if ($dispatch -and -not $numericStable)', 'if ($false)'), + ("RequireSigning = $env:UNSIGNED_RELEASE -ne 'true'", 'RequireSigning = $false'), ]) def test_signed_releases_keep_all_certificate_gates(self): self.assert_policy_mutations_rejected([ - ("if: env.UNSIGNED_RC_RELEASE != 'true'", 'if: false'), + ("if: env.UNSIGNED_RELEASE != 'true'", 'if: false'), ('DS4W_SIGN_CERT_PASSWORD: ${{ secrets.DS4W_SIGN_CERT_PASSWORD }}', 'DS4W_SIGN_CERT_PASSWORD: ignored'), ("'^[0-9A-Fa-f]{40}$'", "'.*'"), ('$signature.Status -ne "Valid"', '$false'), diff --git a/utils/test-release-identity.ps1 b/utils/test-release-identity.ps1 new file mode 100644 index 00000000..86e65c4f --- /dev/null +++ b/utils/test-release-identity.ps1 @@ -0,0 +1,76 @@ +param([string]$WorkflowPath = (Join-Path $PSScriptRoot '../.github/workflows/release.yml')) +$ErrorActionPreference = 'Stop' + +# Execute the real identity gate with a recording GitHub API substitute. +# No release, tag, certificate or network resource is created by this test. +$workflow = Get-Content -LiteralPath $WorkflowPath -Raw +$identity = [regex]::Match($workflow, '(?ms)^ identity:\r?\n(?.*?)^ release:\r?$').Groups['identity'].Value +$match = [regex]::Match($identity, '(?ms)^ run: \|\r?\n(?