diff --git a/DS4Windows/App.xaml.cs b/DS4Windows/App.xaml.cs
index 2fcb171..d754826 100644
--- a/DS4Windows/App.xaml.cs
+++ b/DS4Windows/App.xaml.cs
@@ -643,6 +643,19 @@ private void CheckOptions(ArgumentParser parser)
exitApp = true;
Current.Shutdown(diagnosticExitCode);
}
+ else if (parser.ViiperInstallerPolicy)
+ {
+ // The bundled setup script asking what it is allowed to do.
+ // Runs before the ControlService or any window exists, and
+ // touches nothing except the file it is told to write (plus the
+ // autostart entries, and only when explicitly asked).
+ int policyExitCode =
+ DS4Windows.ViiperInstallerPolicyCommand.Run(
+ parser.ViiperInstallerPolicyArgs);
+ runShutdown = false;
+ exitApp = true;
+ Current.Shutdown(policyExitCode);
+ }
else if (parser.ReenableDevice)
{
DS4Windows.DS4Devices.reEnableDevice(parser.DeviceInstanceId);
@@ -1070,6 +1083,16 @@ private void CleanShutdown()
threadComEvent.Close();
}
+ // The named single-instance event is gone only now. A queued
+ // restart may start its replacement from here and no earlier:
+ // starting it while the event was still open is issue #12, in
+ // which the replacement saw us as the running instance, exited,
+ // and left the user with nothing (and, once the backend became
+ // ours to stop, with no backend either).
+ DS4Windows.PendingApplicationRestart.Current
+ .MarkSingleInstanceReleased();
+ LaunchPendingRestart();
+
if (ipcClassNameMMF != null) ipcClassNameMMF.Dispose();
LogManager.Flush();
@@ -1081,5 +1104,24 @@ private void CleanShutdown()
}
}
}
+
+ private void LaunchPendingRestart()
+ {
+ try
+ {
+ DS4Windows.PendingApplicationRestart.Current.Launch(
+ path => Process.Start(new ProcessStartInfo
+ {
+ FileName = path,
+ UseShellExecute = true,
+ }),
+ line => logHolder?.Logger?.Info(line));
+ }
+ catch (Exception ex)
+ {
+ logHolder?.Logger?.Warn(
+ "Could not restart after VIIPER setup: " + ex.Message);
+ }
+ }
}
}
diff --git a/DS4Windows/ArgumentParser.cs b/DS4Windows/ArgumentParser.cs
index 3dab239..2ffca66 100644
--- a/DS4Windows/ArgumentParser.cs
+++ b/DS4Windows/ArgumentParser.cs
@@ -16,6 +16,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
+using System;
using System.Collections.Generic;
using DS4Windows.DS4Control;
@@ -27,6 +28,8 @@ public class ArgumentParser
private bool stop;
private bool driverinstall;
private bool viiperDriverDiagnostic;
+ private bool viiperInstallerPolicy;
+ private string[] viiperInstallerPolicyArgs = Array.Empty();
private bool reenableDevice;
private string deviceInstanceId;
private bool runtask;
@@ -41,6 +44,15 @@ public class ArgumentParser
public bool Stop { get => stop; }
public bool Driverinstall { get => driverinstall; }
public bool ViiperDriverDiagnostic { get => viiperDriverDiagnostic; }
+ public bool ViiperInstallerPolicy { get => viiperInstallerPolicy; }
+
+ ///
+ /// Everything after -viiperinstallerpolicy, in order: the verb
+ /// and its options. Passed through verbatim rather than parsed here,
+ /// because the verb set belongs to the command, not to this switch
+ /// table.
+ ///
+ public string[] ViiperInstallerPolicyArgs { get => viiperInstallerPolicyArgs; }
public bool ReenableDevice { get => reenableDevice; }
public bool Runtask { get => runtask; }
public bool Command { get => command; }
@@ -72,6 +84,18 @@ public void Parse(string[] args)
viiperDriverDiagnostic = true;
break;
+ // The decision service the bundled VIIPER setup script
+ // consults. Everything that follows belongs to the verb, so
+ // the remaining arguments are taken whole and parsing stops.
+ case "viiperinstallerpolicy":
+ case "-viiperinstallerpolicy":
+ viiperInstallerPolicy = true;
+ viiperInstallerPolicyArgs = new string[args.Length - i - 1];
+ Array.Copy(args, i + 1, viiperInstallerPolicyArgs, 0,
+ viiperInstallerPolicyArgs.Length);
+ i = args.Length;
+ break;
+
case "re-enabledevice":
case "-re-enabledevice":
reenableDevice = true;
diff --git a/DS4Windows/DS4Control/PendingApplicationRestart.cs b/DS4Windows/DS4Control/PendingApplicationRestart.cs
new file mode 100644
index 0000000..f817e65
--- /dev/null
+++ b/DS4Windows/DS4Control/PendingApplicationRestart.cs
@@ -0,0 +1,201 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.IO;
+
+namespace DS4Windows
+{
+ /// Why a queued restart did or did not start a replacement.
+ public enum ViiperRestartLaunchOutcome
+ {
+ /// Nothing asked for a restart.
+ NotRequested,
+
+ ///
+ /// The single-instance handle has not been released yet. Launching now
+ /// is the defect this class exists to prevent.
+ ///
+ SingleInstanceStillHeld,
+
+ /// A replacement was already started for this request.
+ AlreadyLaunched,
+
+ /// The replacement was started.
+ Launched,
+
+ /// Starting the replacement threw.
+ LaunchFailed,
+ }
+
+ ///
+ /// A restart that has been asked for but must not happen yet.
+ ///
+ /// The defect this fixes
+ /// (issue
+ /// #12). The inherited implementation started the replacement process
+ /// and only then asked the dispatcher to shut down. The replacement reaches
+ /// startup in a few hundred milliseconds; the original is still inside an
+ /// up-to-eight-second controller teardown and still owns the named
+ /// single-instance event. So the replacement found an instance already
+ /// running, signalled it, and exited — and the original then finished
+ /// exiting too. The user was left with nothing running, right after the
+ /// install whose purpose was to make virtual controllers work. In this tree
+ /// it is worse: the shutdown also stops the VIIPER backend this process
+ /// owns, so the end state is no application and no backend.
+ ///
+ /// The fix is an ordering, so the ordering is enforced rather than
+ /// commented. A request only records intent.
+ /// refuses to start anything until
+ /// has been called, and that call
+ /// lives at exactly one place: immediately after the shutdown path closes
+ /// the single-instance event. A future edit that moves the launch earlier
+ /// does not reintroduce the race, it fails and says why.
+ ///
+ /// What happens to the backend across the restart. Deliberately
+ /// nothing special: the ordinary stop-on-exit policy from plan task 2.4b
+ /// runs, the owned backend is stopped with the rest of the shutdown, and
+ /// the replacement starts a fresh one on demand when a profile needs it.
+ /// The alternative — exempting an install-driven restart from stop-on-exit —
+ /// would leave a backend running that the new instance does not own and
+ /// therefore would never stop, converting a temporary special case into a
+ /// permanent orphan. A few hundred milliseconds of backend downtime during
+ /// a restart nobody is playing through is the cheaper side of that
+ /// trade.
+ ///
+ public sealed class PendingApplicationRestart
+ {
+ private readonly object gate = new object();
+ private string executablePath;
+ private bool singleInstanceReleased;
+ private bool launched;
+
+ /// The instance the application's shutdown path drains.
+ public static PendingApplicationRestart Current { get; } =
+ new PendingApplicationRestart();
+
+ /// True once a restart has been asked for and not yet run.
+ public bool IsRequested
+ {
+ get { lock (gate) { return executablePath != null && !launched; } }
+ }
+
+ /// The executable a launch would start, or null.
+ public string RequestedExecutable
+ {
+ get { lock (gate) { return executablePath; } }
+ }
+
+ ///
+ /// Records that the application should be replaced by a fresh instance
+ /// once this one has finished shutting down.
+ ///
+ ///
+ /// The executable to start. Callers pass Global.exelocation — the
+ /// executable actually running — rather than a composed
+ /// <product>.exe, so a renamed or relocated copy still
+ /// restarts itself.
+ ///
+ /// Test seam; defaults to the file system.
+ /// False when there is nothing runnable to queue.
+ public bool Request(string exePath, Func fileExists = null)
+ {
+ if (string.IsNullOrWhiteSpace(exePath))
+ {
+ return false;
+ }
+
+ fileExists ??= File.Exists;
+ if (!fileExists(exePath))
+ {
+ return false;
+ }
+
+ lock (gate)
+ {
+ executablePath = exePath;
+ launched = false;
+ return true;
+ }
+ }
+
+ ///
+ /// Called once the named single-instance event has been closed and the
+ /// shutdown is complete. Until this runs, a replacement would see this
+ /// process as the running instance and exit immediately.
+ ///
+ public void MarkSingleInstanceReleased()
+ {
+ lock (gate) { singleInstanceReleased = true; }
+ }
+
+ ///
+ /// Starts the queued replacement, if the ordering allows it.
+ ///
+ ///
+ /// Starts the process. Injected so the ordering can be tested without
+ /// spawning anything.
+ ///
+ /// Receives one line describing the outcome.
+ public ViiperRestartLaunchOutcome Launch(Action start,
+ Action log = null)
+ {
+ string path;
+ lock (gate)
+ {
+ if (executablePath == null)
+ {
+ return ViiperRestartLaunchOutcome.NotRequested;
+ }
+
+ if (launched)
+ {
+ log?.Invoke("A replacement " + ProductInfo.ProductName +
+ " instance was already started; not starting another.");
+ return ViiperRestartLaunchOutcome.AlreadyLaunched;
+ }
+
+ if (!singleInstanceReleased)
+ {
+ log?.Invoke("Restart of " + ProductInfo.ProductName +
+ " was skipped: the single-instance handle is still " +
+ "held, so a replacement would exit immediately.");
+ return ViiperRestartLaunchOutcome.SingleInstanceStillHeld;
+ }
+
+ launched = true;
+ path = executablePath;
+ }
+
+ try
+ {
+ start(path);
+ log?.Invoke("Restarted " + ProductInfo.ProductName +
+ " after VIIPER setup. The backend is started again on " +
+ "demand by the new instance.");
+ return ViiperRestartLaunchOutcome.Launched;
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke("Could not restart " + ProductInfo.ProductName +
+ " after VIIPER setup: " + ex.Message);
+ return ViiperRestartLaunchOutcome.LaunchFailed;
+ }
+ }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs
new file mode 100644
index 0000000..603eafa
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs
@@ -0,0 +1,309 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.Collections.Generic;
+
+namespace DS4Windows
+{
+ ///
+ /// Which bundled component a pin describes.
+ ///
+ public enum ViiperInstallerComponent
+ {
+ /// The usbip-win2 kernel driver package installer.
+ UsbipWin2,
+
+ /// The VIIPER userspace backend executable.
+ ViiperBackend,
+ }
+
+ ///
+ /// One exact file the setup script is allowed to fetch and act on.
+ ///
+ /// A pin is an identity, not a version floor. Everything the script
+ /// needs in order to decide whether the bytes in front of it are the bytes
+ /// this project examined lives here: the URL they come from, their SHA-256,
+ /// their size, and — where the publisher signs — the Authenticode signer
+ /// the certificate chain must resolve to. Part 3 rule 2 of the phased plan
+ /// is the reason there is no "or newer" anywhere in this type.
+ ///
+ /// Nothing in a pin is permission to run the file. It says only "this
+ /// is the artefact whose behaviour was observed"; the tier decision stays
+ /// with , and for the driver package the
+ /// installed pair is re-validated afterwards through the same gate the
+ /// -viiperdriverdiagnostic command uses.
+ ///
+ public sealed class ViiperPinnedDownload
+ {
+ public ViiperPinnedDownload(ViiperInstallerComponent component,
+ string releaseLabel, string fileName, string url, string sha256,
+ long sizeInBytes, bool requireAuthenticode,
+ string expectedSignerCommonName, string digestProvenance,
+ string notes = null)
+ {
+ if (string.IsNullOrWhiteSpace(releaseLabel))
+ throw new ArgumentException("A release label is required.",
+ nameof(releaseLabel));
+ if (string.IsNullOrWhiteSpace(fileName))
+ throw new ArgumentException("A file name is required.",
+ nameof(fileName));
+ if (string.IsNullOrWhiteSpace(url))
+ throw new ArgumentException("A URL is required.", nameof(url));
+ if (string.IsNullOrWhiteSpace(sha256))
+ throw new ArgumentException("A SHA-256 digest is required.",
+ nameof(sha256));
+ if (sizeInBytes <= 0)
+ throw new ArgumentOutOfRangeException(nameof(sizeInBytes));
+ if (requireAuthenticode &&
+ string.IsNullOrWhiteSpace(expectedSignerCommonName))
+ throw new ArgumentException(
+ "An expected signer is required when Authenticode is required.",
+ nameof(expectedSignerCommonName));
+ if (string.IsNullOrWhiteSpace(digestProvenance))
+ throw new ArgumentException(
+ "How the digest was obtained has to be recorded.",
+ nameof(digestProvenance));
+
+ Component = component;
+ ReleaseLabel = releaseLabel;
+ FileName = fileName;
+ Url = url;
+ Sha256 = NormalizeDigest(sha256);
+ SizeInBytes = sizeInBytes;
+ RequireAuthenticode = requireAuthenticode;
+ ExpectedSignerCommonName = requireAuthenticode
+ ? expectedSignerCommonName
+ : null;
+ DigestProvenance = digestProvenance;
+ Notes = notes;
+ }
+
+ public ViiperInstallerComponent Component { get; }
+
+ ///
+ /// Upstream release label, e.g. 0.9.7.7 or v0.0.5. For the
+ /// driver this is the label a carries,
+ /// which is what ties a download to a tier.
+ ///
+ public string ReleaseLabel { get; }
+
+ public string FileName { get; }
+
+ public string Url { get; }
+
+ /// Upper-case hexadecimal, no separators.
+ public string Sha256 { get; }
+
+ ///
+ /// Exact published size. Not a security control on its own — a digest
+ /// match implies it — but it lets a truncated download be named as such
+ /// instead of being reported as a digest mismatch.
+ ///
+ public long SizeInBytes { get; }
+
+ ///
+ /// Whether a valid Authenticode chain is a precondition for execution.
+ /// False only where the publisher does not sign at all, and then the
+ /// reason is spelled out in .
+ ///
+ public bool RequireAuthenticode { get; }
+
+ ///
+ /// Common name the signing certificate must carry, or null when
+ /// is false. Compared only after
+ /// Windows has already accepted the chain — it narrows a valid
+ /// signature to the expected publisher, it never substitutes for chain
+ /// validation.
+ ///
+ public string ExpectedSignerCommonName { get; }
+
+ ///
+ /// How this project obtained . Recorded in source
+ /// because a pinned digest whose provenance nobody can restate is a
+ /// number, not evidence.
+ ///
+ public string DigestProvenance { get; }
+
+ /// Anything a reader needs in order not to misread the pin.
+ public string Notes { get; }
+
+ public bool MatchesDigest(string candidate) =>
+ !string.IsNullOrWhiteSpace(candidate) &&
+ string.Equals(NormalizeDigest(candidate), Sha256,
+ StringComparison.Ordinal);
+
+ ///
+ /// Case-insensitive, separator-free upper hex. Callers hand us digests
+ /// from Get-FileHash, from GitHub's release metadata and from
+ /// , and those three
+ /// disagree about case and about colons.
+ ///
+ public static string NormalizeDigest(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ string text = value.Trim();
+ int separator = text.IndexOf(':');
+ if (separator >= 0)
+ {
+ text = text.Substring(separator + 1);
+ }
+
+ return text.Replace("-", string.Empty).Replace(" ", string.Empty)
+ .Trim().ToUpperInvariant();
+ }
+ }
+
+ ///
+ /// The exact artefacts extras/install-viiper-backend.ps1 may fetch.
+ ///
+ /// Single source of truth on purpose: the script holds no URL and no
+ /// digest of its own, it asks for these. That is what makes "never fall
+ /// back to latest" enforceable rather than aspirational — there is no
+ /// second place a fallback could be written.
+ ///
+ public static class ViiperInstallerPins
+ {
+ ///
+ /// usbip-win2 0.9.7.7 x64, the release this project inspected
+ /// byte-for-byte and validated on a clean Windows 11 checkpoint.
+ ///
+ /// Not the newest release, deliberately. 0.9.7.8 exists and is
+ /// the baseline the maintainer's own machine carries, but it is the
+ /// release the request-lifetime race was reproduced on, so it is
+ /// recognised by and never
+ /// installed by us.
+ ///
+ public static ViiperPinnedDownload UsbipWin2 { get; } =
+ new ViiperPinnedDownload(
+ component: ViiperInstallerComponent.UsbipWin2,
+ releaseLabel: "0.9.7.7",
+ fileName: "USBip-0.9.7.7-x64.exe",
+ url: "https://github.com/vadimgrn/usbip-win2/releases/download/" +
+ "v.0.9.7.7/USBip-0.9.7.7-x64.exe",
+ sha256:
+ "51620FA5F9F8BE5932BC9D786DEEE557CE06D5407A99CAB490DCFAC71F185FEA",
+ sizeInBytes: 33226344L,
+ requireAuthenticode: true,
+ expectedSignerCommonName:
+ "Cloudyne Systems (Scheibling Consulting AB)",
+ digestProvenance:
+ "SHA-256 of the release asset downloaded for the controlled " +
+ "Windows 11 validation pass, recomputed from the retained " +
+ "local copy before pinning.",
+ notes:
+ "Inno Setup 6.7.0 payload. Installs UDE DriverVer 21.14.27.907 " +
+ "and filter DriverVer 21.14.27.661; the installed pair is " +
+ "re-validated after setup rather than trusted from this pin.");
+
+ ///
+ /// VIIPER v0.0.5, the backend release whose framed audio/haptics
+ /// protocol this application negotiates against.
+ ///
+ /// Two things a reader has to know. First, upstream does not sign
+ /// this asset at all — it is an unsigned Go binary published by a
+ /// release workflow — so the digest is the whole identity and
+ /// is false
+ /// rather than a check that would fail on every honest download.
+ /// Second, the published binary is mis-stamped: it reports
+ /// v0.0.3-18-g02fffe6 as its own version because the release
+ /// workflow built it without fetching tags. hbashton/VIIPER#3 (ours)
+ /// fixes that workflow. Until it lands, nothing may validate this file
+ /// by the version it claims — only by
+ /// .
+ ///
+ public static ViiperPinnedDownload ViiperBackend { get; } =
+ new ViiperPinnedDownload(
+ component: ViiperInstallerComponent.ViiperBackend,
+ releaseLabel: "v0.0.5",
+ fileName: "viiper.exe",
+ url: "https://github.com/hbashton/VIIPER/releases/download/" +
+ "v0.0.5/viiper.exe",
+ sha256:
+ "3AD872D006DF2FC282E381A68B5A5B3C51E4DA3614D250AB3FDA1C272EF745D0",
+ sizeInBytes: 11255296L,
+ requireAuthenticode: false,
+ expectedSignerCommonName: null,
+ digestProvenance:
+ "Computed locally from the downloaded hbashton/VIIPER v0.0.5 " +
+ "asset and cross-checked against the digest GitHub reports " +
+ "for that same release asset.",
+ notes:
+ "Unsigned upstream. The asset mis-reports its own version as " +
+ "v0.0.3-18-g02fffe6 (hbashton/VIIPER#3 fixes the release " +
+ "workflow), so the embedded version string is never a " +
+ "validation input.");
+
+ ///
+ /// The version string the pinned VIIPER asset reports about itself.
+ /// Recorded so a diagnostic can say "this is the known mis-stamp"
+ /// instead of a reader concluding the wrong file was downloaded.
+ ///
+ public const string ViiperBackendEmbeddedVersionMisstamp =
+ "v0.0.3-18-g02fffe6";
+
+ public static IReadOnlyList All { get; } =
+ new[] { UsbipWin2, ViiperBackend };
+
+ public static ViiperPinnedDownload For(ViiperInstallerComponent component)
+ {
+ switch (component)
+ {
+ case ViiperInstallerComponent.UsbipWin2:
+ return UsbipWin2;
+ case ViiperInstallerComponent.ViiperBackend:
+ return ViiperBackend;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(component));
+ }
+ }
+
+ ///
+ /// Parses the component token the setup script passes on the command
+ /// line. Returns false for anything unrecognised rather than guessing —
+ /// a typo must not silently verify the wrong file against the wrong pin.
+ ///
+ public static bool TryParseComponent(string token,
+ out ViiperInstallerComponent component)
+ {
+ component = default;
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return false;
+ }
+
+ switch (token.Trim().ToLowerInvariant())
+ {
+ case "usbip":
+ case "usbip-win2":
+ component = ViiperInstallerComponent.UsbipWin2;
+ return true;
+ case "viiper":
+ case "viiper-backend":
+ component = ViiperInstallerComponent.ViiperBackend;
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs
new file mode 100644
index 0000000..094d71a
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs
@@ -0,0 +1,790 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace DS4Windows
+{
+ ///
+ /// What the setup script should do about the usbip-win2 kernel driver.
+ ///
+ public enum ViiperUsbipInstallAction
+ {
+ ///
+ /// Nothing recognisable is installed. Fetch the pinned installer,
+ /// verify it, and run it.
+ ///
+ InstallPinned,
+
+ ///
+ /// The pinned release is already the installed one. Do nothing.
+ ///
+ AlreadyPinned,
+
+ ///
+ /// A different release the manifest knows is installed. Report what it
+ /// is and leave it completely alone — replacing a bound kernel package
+ /// with an older one is not a repair.
+ ///
+ LeaveRecognisedReleaseAlone,
+
+ ///
+ /// Something is installed that cannot be matched to a manifest entry.
+ /// Touch nothing and do not report success.
+ ///
+ RefuseUnrecognisedInstall,
+ }
+
+ ///
+ /// A decision plus the log lines that justify it. The lines are the
+ /// audit trail requirement 5 of plan task 2.4 asks for, produced by the
+ /// same pure function that makes the call so the two cannot disagree.
+ ///
+ public sealed class ViiperInstallerDecision
+ {
+ public ViiperInstallerDecision(TAction action, string summary,
+ IReadOnlyList lines)
+ {
+ Action = action;
+ Summary = summary ?? string.Empty;
+ Lines = lines ?? Array.Empty();
+ }
+
+ public TAction Action { get; }
+
+ /// One sentence naming the decision and its reason.
+ public string Summary { get; }
+
+ ///
+ /// Every input the decision was made from, expected value beside
+ /// observed value, in evaluation order.
+ ///
+ public IReadOnlyList Lines { get; }
+ }
+
+ ///
+ /// What was actually observed about a downloaded file. Split from the
+ /// decision so the decision is a pure function of facts and the facts come
+ /// from one thin, untested I/O helper rather than from the middle of the
+ /// policy.
+ ///
+ public sealed class ViiperDownloadObservation
+ {
+ /// The file exists and could be opened.
+ public bool Exists { get; init; }
+
+ public long SizeInBytes { get; init; }
+
+ /// Hex SHA-256, or null when it could not be computed.
+ public string Sha256 { get; init; }
+
+ ///
+ /// Whether Authenticode was evaluated at all. False when the pin does
+ /// not require it; a pin that requires it and an observation that
+ /// skipped it is an error, not a pass.
+ ///
+ public bool SignatureEvaluated { get; init; }
+
+ /// Windows accepted the certificate chain under normal policy.
+ public bool SignatureTrusted { get; init; }
+
+ /// Common name read off the verified chain, or null.
+ public string SignerCommonName { get; init; }
+
+ /// Short non-sensitive reason a signature was rejected.
+ public string SignatureDiagnostic { get; init; }
+
+ /// Why the file could not be examined at all, or null.
+ public string ObservationError { get; init; }
+ }
+
+ /// Verdict on one downloaded file.
+ public enum ViiperDownloadVerdict
+ {
+ /// Digest and (where required) signature both matched the pin.
+ Approved,
+
+ /// The file was not there, or could not be read.
+ Unavailable,
+
+ /// The bytes are not the pinned bytes.
+ DigestMismatch,
+
+ /// Windows would not accept the signature.
+ SignatureNotTrusted,
+
+ /// A valid signature, but not the expected publisher.
+ UnexpectedSigner,
+ }
+
+ /// How a post-install validation attempt turned out.
+ public enum ViiperPostInstallVerdict
+ {
+ /// The gate validated the installed package pair.
+ Validated,
+
+ /// The gate ran and refused the installed pair.
+ Refused,
+
+ ///
+ /// The gate could not run, or its result could not be obtained. Treated
+ /// exactly like a refusal: an unverifiable state blocks.
+ ///
+ CouldNotRun,
+ }
+
+ ///
+ /// Every decision extras/install-viiper-backend.ps1 makes, as pure
+ /// total functions over observed facts.
+ ///
+ /// Why this is C# and not PowerShell. The admission rule is
+ /// "the manifest decides", and the manifest is
+ /// — a type whose own contract says it
+ /// must not be duplicated into the UI, the broker or the installer. A
+ /// PowerShell copy of the version table would be exactly that duplicate,
+ /// and it would be the copy that decides whether a kernel driver gets
+ /// installed. Keeping the decisions here means one table, one set of
+ /// comparisons, and coverage from the test suite that already gates every
+ /// merge.
+ ///
+ /// The script keeps the mechanical half — fetching bytes, running an
+ /// installer, replacing a file — and consults these functions through
+ /// for every branch that can end
+ /// in something being executed.
+ ///
+ public static class ViiperInstallerPolicy
+ {
+ /// Setup finished and the installed package pair validated.
+ public const int ScriptExitSuccess = 0;
+
+ /// Setup refused, failed, or could not verify something.
+ public const int ScriptExitFailed = 1;
+
+ ///
+ /// Setup completed its file work, but the driver package pair cannot be
+ /// validated until Windows restarts. Distinct from success because
+ /// nothing has been proven yet, and distinct from failure because
+ /// nothing is known to be wrong.
+ ///
+ public const int ScriptExitRestartRequired = 3;
+
+ ///
+ /// Decides what to do about the installed usbip-win2 driver.
+ ///
+ /// The primary input is the gate's four-state answer, not a file
+ /// version. usbip2_ude.sys carries a DriverVer such as
+ /// 1.45.29.368 that has nothing to do with the 0.9.7.x
+ /// release label, which is why upstream's -ge 0.9.7.7 floor
+ /// passes trivially on every install it has ever seen.
+ ///
+ /// The gate's readiness state.
+ ///
+ /// The manifest release the installed pair matched, or null.
+ ///
+ /// Tier of that release, or null.
+ ///
+ /// The release label a usbip-win2 uninstall entry reports, if any. Only
+ /// consulted when the gate found no bound packages, so a
+ /// half-installed or reboot-pending machine is not mistaken for an
+ /// empty one.
+ ///
+ /// The release this project would install.
+ /// The releases the product recognises.
+ public static ViiperInstallerDecision
+ DecideUsbipInstall(ViiperDriverReadinessState state,
+ string matchedReleaseLabel, ViiperDriverTier? matchedTier,
+ string reportedUninstallRelease, ViiperPinnedDownload pin,
+ ViiperDriverManifest manifest)
+ {
+ if (pin == null) throw new ArgumentNullException(nameof(pin));
+ manifest ??= ViiperDriverManifest.ObservedBaselines;
+
+ List lines = new List
+ {
+ "usbip-win2 pinned release: " + pin.ReleaseLabel + " (" +
+ pin.FileName + ").",
+ "usbip-win2 installed state: " + DescribeState(state) +
+ "; matched release " + Present(matchedReleaseLabel) +
+ "; tier " + (matchedTier.HasValue
+ ? matchedTier.Value.ToString() : "(none)") + ".",
+ };
+
+ switch (state)
+ {
+ case ViiperDriverReadinessState.ValidatedExperimental:
+ case ViiperDriverReadinessState.Approved:
+ if (LabelsMatch(matchedReleaseLabel, pin.ReleaseLabel))
+ {
+ return Decide(ViiperUsbipInstallAction.AlreadyPinned,
+ "usbip-win2 " + pin.ReleaseLabel +
+ " is already installed and matches the pinned " +
+ "release exactly; the driver step is skipped.",
+ lines);
+ }
+
+ lines.Add(
+ "The installed release is recognised but is not the " +
+ "pinned one. Recognising a release is not approving " +
+ "it, and replacing a bound kernel package with a " +
+ "different one is not a repair.");
+ return Decide(
+ ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone,
+ "usbip-win2 " + Present(matchedReleaseLabel) +
+ " is installed. It is a release this build recognises " +
+ "as an experimental baseline, and it is left exactly " +
+ "as it is.",
+ lines);
+
+ case ViiperDriverReadinessState.Missing:
+ return DecideWhenNothingIsBound(reportedUninstallRelease,
+ pin, manifest, lines);
+
+ case ViiperDriverReadinessState.DetectedUnvalidated:
+ lines.Add(
+ "A usbip-win2 package is present that could not be " +
+ "matched to any release this build knows, or whose " +
+ "trust could not be established.");
+ return Decide(
+ ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ "Setup will not touch the installed usbip-win2 " +
+ "packages: they do not match any release this build " +
+ "recognises. Nothing is installed, removed or " +
+ "downgraded.",
+ lines);
+
+ default:
+ // An enum value from a future build is not a licence to act.
+ lines.Add("Unrecognised readiness state value '" +
+ ((int)state).ToString(CultureInfo.InvariantCulture) +
+ "'; treated as unverifiable.");
+ return Decide(
+ ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ "Setup cannot establish what usbip-win2 packages are " +
+ "installed, so it will not touch them.",
+ lines);
+ }
+ }
+
+ private static ViiperInstallerDecision
+ DecideWhenNothingIsBound(string reportedUninstallRelease,
+ ViiperPinnedDownload pin, ViiperDriverManifest manifest,
+ List lines)
+ {
+ string reported = (reportedUninstallRelease ?? string.Empty).Trim();
+ lines.Add("usbip-win2 uninstall entry reports: " + Present(reported) +
+ ".");
+
+ if (reported.Length == 0)
+ {
+ return Decide(ViiperUsbipInstallAction.InstallPinned,
+ "No usbip-win2 driver is installed. Setup will install the " +
+ "pinned release " + pin.ReleaseLabel + " after verifying it.",
+ lines);
+ }
+
+ if (LabelsMatch(reported, pin.ReleaseLabel))
+ {
+ lines.Add(
+ "The pinned release is registered but its packages are not " +
+ "bound. Re-running the pinned installer is the repair for " +
+ "that; it is the same release, so nothing is downgraded.");
+ return Decide(ViiperUsbipInstallAction.InstallPinned,
+ "usbip-win2 " + pin.ReleaseLabel + " is registered but not " +
+ "in service. Setup will reinstall the same pinned release " +
+ "after verifying it.",
+ lines);
+ }
+
+ if (manifest.Releases.Any(release =>
+ LabelsMatch(release.ReleaseLabel, reported)))
+ {
+ lines.Add(
+ "A different recognised release is registered. Setup will " +
+ "not install over it: that would be a downgrade of a " +
+ "kernel driver, decided by this script rather than by the " +
+ "person who installed it.");
+ return Decide(
+ ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone,
+ "usbip-win2 " + reported + " is registered on this machine " +
+ "but its packages are not currently in service. Setup " +
+ "leaves it alone; a Windows restart may be needed.",
+ lines);
+ }
+
+ lines.Add(
+ "The registered release is not one this build recognises.");
+ return Decide(ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ "usbip-win2 " + reported + " is registered on this machine and " +
+ "is not a release this build recognises. Setup will not " +
+ "install, replace or remove it.",
+ lines);
+ }
+
+ ///
+ /// Decides whether a downloaded file may be executed or installed.
+ /// Every branch other than
+ /// means the file is not touched again.
+ ///
+ public static ViiperInstallerDecision
+ DecideDownloadVerification(ViiperPinnedDownload pin,
+ ViiperDownloadObservation observation)
+ {
+ if (pin == null) throw new ArgumentNullException(nameof(pin));
+
+ List lines = new List
+ {
+ "Verifying " + pin.FileName + " against pinned release " +
+ pin.ReleaseLabel + ".",
+ "Source: " + pin.Url,
+ };
+
+ if (observation == null || !observation.Exists)
+ {
+ lines.Add("File present: expected yes, actual no" +
+ (observation?.ObservationError is string missingError &&
+ missingError.Length > 0
+ ? " (" + missingError + ")"
+ : string.Empty) + ".");
+ return Decide(ViiperDownloadVerdict.Unavailable,
+ "Verification failed: " + pin.FileName +
+ " is not present, so nothing about it can be verified.",
+ lines);
+ }
+
+ lines.Add("Size: expected " +
+ pin.SizeInBytes.ToString(CultureInfo.InvariantCulture) +
+ " bytes, actual " +
+ observation.SizeInBytes.ToString(CultureInfo.InvariantCulture) +
+ " bytes.");
+
+ string actualDigest =
+ ViiperPinnedDownload.NormalizeDigest(observation.Sha256);
+ lines.Add("SHA-256: expected " + pin.Sha256 + ", actual " +
+ (actualDigest.Length == 0 ? "(not computed)" : actualDigest) +
+ ".");
+
+ if (actualDigest.Length == 0)
+ {
+ if (!string.IsNullOrWhiteSpace(observation.ObservationError))
+ {
+ lines.Add("Digest could not be computed: " +
+ observation.ObservationError + ".");
+ }
+
+ return Decide(ViiperDownloadVerdict.Unavailable,
+ "Verification failed: the SHA-256 of " + pin.FileName +
+ " could not be computed, so it is treated as unverified.",
+ lines);
+ }
+
+ if (!pin.MatchesDigest(actualDigest))
+ {
+ return Decide(ViiperDownloadVerdict.DigestMismatch,
+ "Verification failed: " + pin.FileName + " does not have " +
+ "the pinned SHA-256. The file is discarded and nothing is " +
+ "run from it.",
+ lines);
+ }
+
+ if (!pin.RequireAuthenticode)
+ {
+ lines.Add("Authenticode: not required for this component (" +
+ "upstream publishes it unsigned), so the pinned SHA-256 is " +
+ "the whole identity check.");
+ return Decide(ViiperDownloadVerdict.Approved,
+ pin.FileName + " matches the pinned SHA-256 for release " +
+ pin.ReleaseLabel + ".",
+ lines);
+ }
+
+ if (!observation.SignatureEvaluated)
+ {
+ lines.Add("Authenticode: expected a verified chain, actual " +
+ "(not evaluated).");
+ return Decide(ViiperDownloadVerdict.Unavailable,
+ "Verification failed: the Authenticode signature of " +
+ pin.FileName + " was never evaluated, and an unevaluated " +
+ "signature is not a valid one.",
+ lines);
+ }
+
+ lines.Add("Authenticode chain: expected trusted under normal " +
+ "Windows policy, actual " +
+ (observation.SignatureTrusted ? "trusted" : "not trusted") +
+ (string.IsNullOrWhiteSpace(observation.SignatureDiagnostic)
+ ? string.Empty
+ : " (" + observation.SignatureDiagnostic + ")") + ".");
+
+ if (!observation.SignatureTrusted)
+ {
+ return Decide(ViiperDownloadVerdict.SignatureNotTrusted,
+ "Verification failed: Windows does not accept the " +
+ "Authenticode signature on " + pin.FileName + ".",
+ lines);
+ }
+
+ string signer = (observation.SignerCommonName ?? string.Empty).Trim();
+ lines.Add("Authenticode signer: expected \"" +
+ pin.ExpectedSignerCommonName + "\", actual " +
+ (signer.Length == 0 ? "(not reported)" : "\"" + signer + "\"") +
+ ".");
+
+ if (!string.Equals(signer, pin.ExpectedSignerCommonName,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return Decide(ViiperDownloadVerdict.UnexpectedSigner,
+ "Verification failed: " + pin.FileName + " carries a valid " +
+ "signature from a different publisher than the pinned one.",
+ lines);
+ }
+
+ return Decide(ViiperDownloadVerdict.Approved,
+ pin.FileName + " matches the pinned SHA-256 and is signed by " +
+ "the pinned publisher.",
+ lines);
+ }
+
+ ///
+ /// Maps the exit code of -viiperdriverdiagnostic onto a verdict.
+ /// Anything other than a clean pass blocks; "could not run" is a
+ /// failure, not a neutral outcome.
+ ///
+ ///
+ /// False when the diagnostic could not be started at all — for example
+ /// when the application executable is not next to the script.
+ ///
+ public static ViiperInstallerDecision
+ DecidePostInstallValidation(bool diagnosticRan, int exitCode)
+ {
+ List lines = new List();
+
+ if (!diagnosticRan)
+ {
+ lines.Add("Post-install validation: the driver diagnostic could " +
+ "not be started.");
+ return Decide(ViiperPostInstallVerdict.CouldNotRun,
+ "The installed usbip-win2 package pair could not be " +
+ "validated because the diagnostic could not run. An " +
+ "unverifiable state is treated as a failure.",
+ lines);
+ }
+
+ lines.Add("Post-install validation: driver diagnostic exit code " +
+ exitCode.ToString(CultureInfo.InvariantCulture) + ".");
+
+ switch (exitCode)
+ {
+ case ViiperDriverValidationCommand.ExitCodePassed:
+ return Decide(ViiperPostInstallVerdict.Validated,
+ "The installed usbip-win2 package pair matches a " +
+ "release this build recognises, and its catalogs are " +
+ "trusted.",
+ lines);
+
+ case ViiperDriverValidationCommand.ExitCodeFailed:
+ return Decide(ViiperPostInstallVerdict.Refused,
+ "The installed usbip-win2 package pair was refused by " +
+ "the driver gate. Virtual controllers stay blocked " +
+ "until it validates.",
+ lines);
+
+ case ViiperDriverValidationCommand.ExitCodeError:
+ return Decide(ViiperPostInstallVerdict.CouldNotRun,
+ "The driver diagnostic could not complete, so the " +
+ "installed package pair is unverified. An unverifiable " +
+ "state is treated as a failure.",
+ lines);
+
+ default:
+ lines.Add("The exit code is not one the diagnostic " +
+ "documents (0 passed, 1 failed, 2 could not run).");
+ return Decide(ViiperPostInstallVerdict.CouldNotRun,
+ "The driver diagnostic returned an exit code this build " +
+ "does not recognise, so its result cannot be trusted.",
+ lines);
+ }
+ }
+
+ ///
+ /// The final exit code of the setup script, from the two facts that
+ /// decide it. Kept here rather than in the script so the app-side
+ /// interpretation in is provably the
+ /// inverse of what the script produces.
+ ///
+ public static int ResolveScriptExitCode(ViiperPostInstallVerdict verdict,
+ bool restartPending)
+ {
+ if (verdict == ViiperPostInstallVerdict.Validated)
+ {
+ return ScriptExitSuccess;
+ }
+
+ // A restart that Windows itself asked for explains an unvalidated
+ // pair without anything being wrong. It is still not success.
+ return restartPending ? ScriptExitRestartRequired : ScriptExitFailed;
+ }
+
+ ///
+ /// What the application should tell the user, and do, when the setup
+ /// script exits.
+ ///
+ public sealed class ViiperInstallerExitReport
+ {
+ public bool Succeeded { get; init; }
+
+ /// Restart the application to pick up the new state.
+ public bool RestartApplication { get; init; }
+
+ /// True when the message should be shown as an error.
+ public bool IsError { get; init; }
+
+ public string Message { get; init; }
+ }
+
+ ///
+ /// Interprets the script's exit code together with the freshly
+ /// re-probed prerequisite state.
+ ///
+ /// The script's process exit code.
+ /// Whether the backend can now run.
+ /// Where the script wrote its decisions.
+ public static ViiperInstallerExitReport DescribeInstallerExit(
+ int exitCode, bool ready, string logPath)
+ {
+ string productName = ProductInfo.ProductName;
+ string logSuffix = string.IsNullOrWhiteSpace(logPath)
+ ? string.Empty
+ : "\n\nEvery decision setup made was written to:\n" + logPath;
+
+ if (exitCode == ScriptExitSuccess && ready)
+ {
+ return new ViiperInstallerExitReport
+ {
+ Succeeded = true,
+ RestartApplication = true,
+ IsError = false,
+ Message = "VIIPER setup finished. The installed usbip-win2 " +
+ "package pair was validated. Restarting " + productName +
+ ".",
+ };
+ }
+
+ if (exitCode == ScriptExitRestartRequired)
+ {
+ return new ViiperInstallerExitReport
+ {
+ Succeeded = false,
+ RestartApplication = false,
+ IsError = false,
+ Message = "VIIPER was installed, but Windows has to restart " +
+ "before the driver packages can be validated. Restart " +
+ "Windows once, then use Refresh." + logSuffix,
+ };
+ }
+
+ if (exitCode == ScriptExitSuccess)
+ {
+ // Setup validated the driver, but the backend still is not
+ // answering. Nothing is known to be broken; nothing is proven
+ // working either.
+ return new ViiperInstallerExitReport
+ {
+ Succeeded = false,
+ RestartApplication = false,
+ IsError = false,
+ Message = "VIIPER setup reported success, but " + productName +
+ " cannot see every component as ready yet. Restart " +
+ "Windows once, then use Refresh." + logSuffix,
+ };
+ }
+
+ return new ViiperInstallerExitReport
+ {
+ Succeeded = false,
+ RestartApplication = false,
+ IsError = true,
+ Message = "VIIPER setup did not finish (exit code " +
+ exitCode.ToString(CultureInfo.InvariantCulture) + ").\n\n" +
+ "Setup refuses rather than guesses: an unverified download, " +
+ "an unrecognised driver package, or a backend it could not " +
+ "stop all end here, and none of them change anything on the " +
+ "machine." + logSuffix,
+ };
+ }
+
+ ///
+ /// What to do about VIIPER autostart entries that already exist.
+ ///
+ /// Setup never creates them, so anything found was created by
+ /// something else — a previous install, the upstream script, or
+ /// viiper.exe install run by hand. Adopting that silently would
+ /// leave a backend running before the application starts, which is a
+ /// backend the application will never own, never stop, and — because
+ /// neither mechanism passes --update-notify none — one whose
+ /// self-updater is live.
+ ///
+ /// What the read-only detector found.
+ ///
+ /// Whether the user asked, in this run, for them to be removed.
+ ///
+ public static ViiperInstallerDecision
+ PlanAutostartRemoval(ViiperAutostartStatus status,
+ bool removalRequested)
+ {
+ List lines = new List();
+
+ if (status == null)
+ {
+ lines.Add("VIIPER autostart: could not be inspected.");
+ return Decide(ViiperAutostartPlanAction.CouldNotInspect,
+ "Setup could not check whether VIIPER starts at logon.",
+ lines);
+ }
+
+ if (!string.IsNullOrEmpty(status.InspectionError))
+ {
+ lines.Add("VIIPER autostart: inspection error - " +
+ status.InspectionError + ".");
+ return Decide(ViiperAutostartPlanAction.CouldNotInspect,
+ "Setup could not check whether VIIPER starts at logon: " +
+ status.InspectionError + ".",
+ lines);
+ }
+
+ if (!status.Any)
+ {
+ lines.Add("VIIPER autostart: none found. Setup does not create " +
+ "any: " + ProductInfo.ProductName + " starts the backend " +
+ "when a profile needs it and stops it on exit.");
+ return Decide(ViiperAutostartPlanAction.NothingToDo,
+ "VIIPER does not start at logon, and setup does not make " +
+ "it start at logon.",
+ lines);
+ }
+
+ foreach (ViiperAutostartEntry entry in status.Entries)
+ {
+ lines.Add("VIIPER autostart found: " + entry.Description +
+ " -> " + entry.Target);
+ }
+
+ lines.Add("Neither autostart mechanism passes --update-notify none, " +
+ "so a backend started by one of them runs with its self-updater " +
+ "enabled (issue #8).");
+
+ if (removalRequested)
+ {
+ return Decide(ViiperAutostartPlanAction.Remove,
+ "Removing " + status.Entries.Count +
+ " existing VIIPER autostart entr" +
+ (status.Entries.Count == 1 ? "y" : "ies") +
+ " at your request.",
+ lines);
+ }
+
+ lines.Add("Left in place: removing somebody else's autostart entry " +
+ "without being asked is not setup's decision.");
+ return Decide(ViiperAutostartPlanAction.OfferRemoval,
+ "VIIPER is set to start at logon by an entry setup did not " +
+ "create. " + ProductInfo.ProductName + " does not need it, and " +
+ "Settings has one-click removal.",
+ lines);
+ }
+
+ private static ViiperInstallerDecision Decide(T action,
+ string summary, List lines)
+ {
+ lines.Add("Decision: " + summary);
+ return new ViiperInstallerDecision(action, summary,
+ lines.ToArray());
+ }
+
+ private static bool LabelsMatch(string left, string right)
+ {
+ if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
+ {
+ return false;
+ }
+
+ return string.Equals(NormalizeLabel(left), NormalizeLabel(right),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Release labels reach us as 0.9.7.7, v.0.9.7.7 and
+ /// v0.0.5 depending on who wrote them down. Only the leading
+ /// v/v. is normalised away; the digits are compared
+ /// literally, because "close enough" is how a floor comparison gets
+ /// reinvented.
+ ///
+ private static string NormalizeLabel(string label)
+ {
+ string text = label.Trim();
+ if (text.StartsWith("v.", StringComparison.OrdinalIgnoreCase))
+ {
+ return text.Substring(2).Trim();
+ }
+
+ if (text.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ {
+ return text.Substring(1).Trim();
+ }
+
+ return text;
+ }
+
+ private static string DescribeState(ViiperDriverReadinessState state)
+ {
+ switch (state)
+ {
+ case ViiperDriverReadinessState.Missing:
+ return "no packages bound";
+ case ViiperDriverReadinessState.DetectedUnvalidated:
+ return "present but unvalidated";
+ case ViiperDriverReadinessState.ValidatedExperimental:
+ return "matches a recognised experimental baseline";
+ case ViiperDriverReadinessState.Approved:
+ return "matches an approved release";
+ default:
+ return "unknown";
+ }
+ }
+
+ private static string Present(string value) =>
+ string.IsNullOrWhiteSpace(value) ? "(none)" : value;
+ }
+
+ /// What setup should do about pre-existing VIIPER autostart.
+ public enum ViiperAutostartPlanAction
+ {
+ /// No entry exists, and setup creates none.
+ NothingToDo,
+
+ /// Entries exist; report them and point at the removal switch.
+ OfferRemoval,
+
+ /// Entries exist and removal was explicitly requested.
+ Remove,
+
+ /// The check itself failed; never reported as "none".
+ CouldNotInspect,
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs
new file mode 100644
index 0000000..c549703
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs
@@ -0,0 +1,464 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DS4Windows
+{
+ ///
+ /// The -viiperinstallerpolicy switch: the read-only decision service
+ /// extras/install-viiper-backend.ps1 consults before it does
+ /// anything irreversible.
+ ///
+ /// Contract with the script. Every verb writes a UTF-8
+ /// key=value file at --out and returns an exit code. Results
+ /// go to a file rather than to stdout because this is a WPF (GUI subsystem)
+ /// process: whether its console output reaches a caller's pipe depends on
+ /// how the caller launched it, and a verification result that sometimes
+ /// arrives is not a verification result. Lines beginning log= are the
+ /// decision audit trail and the script copies them verbatim into
+ /// install.log.
+ ///
+ /// Read-only with respect to the machine, with exactly one
+ /// exception: autostart --remove, which is reached only when the
+ /// person running setup asked for it. Nothing here installs, elevates,
+ /// attaches, starts a backend, or touches a driver.
+ ///
+ public static class ViiperInstallerPolicyCommand
+ {
+ /// The verb produced a decision. Read it from the out-file.
+ public const int ExitDecided = 0;
+
+ /// The verb produced a refusal.
+ public const int ExitRefused = 1;
+
+ ///
+ /// The verb could not run: a bad argument list, an unwritable out-file,
+ /// or an unexpected failure. The script treats this exactly like a
+ /// refusal; it is distinct only so the cause is visible in the log.
+ ///
+ public const int ExitCouldNotRun = 2;
+
+ private const string VerbPins = "pins";
+ private const string VerbVerifyFile = "verify-file";
+ private const string VerbUsbipDecision = "usbip-decision";
+ private const string VerbValidateInstalled = "validate-installed";
+ private const string VerbAutostart = "autostart";
+
+ ///
+ /// Runs one verb. is everything after the
+ /// -viiperinstallerpolicy switch.
+ ///
+ public static int Run(IReadOnlyList args)
+ {
+ List output = new List();
+ int exitCode;
+
+ try
+ {
+ exitCode = Dispatch(args ?? Array.Empty(), output);
+ }
+ catch (Exception ex)
+ {
+ output.Add("error=" + Sanitize(ex.GetType().Name + ": " +
+ ex.Message));
+ output.Add("log=Installer policy could not run: " +
+ Sanitize(ex.Message));
+ exitCode = ExitCouldNotRun;
+ }
+
+ output.Insert(0, "exitcode=" +
+ exitCode.ToString(CultureInfo.InvariantCulture));
+
+ string outPath = ReadOption(args, "--out");
+ if (!TryWrite(outPath, output))
+ {
+ // The script cannot read a decision it never received, and it
+ // must not proceed on silence.
+ return ExitCouldNotRun;
+ }
+
+ return exitCode;
+ }
+
+ private static int Dispatch(IReadOnlyList args,
+ List output)
+ {
+ string verb = args.Count > 0 ? args[0].Trim().ToLowerInvariant() : null;
+ switch (verb)
+ {
+ case VerbPins:
+ return EmitPins(output);
+ case VerbVerifyFile:
+ return VerifyFile(args, output);
+ case VerbUsbipDecision:
+ return DecideUsbip(args, output);
+ case VerbValidateInstalled:
+ return ValidateInstalled(output);
+ case VerbAutostart:
+ return PlanAutostart(args, output);
+ default:
+ output.Add("error=unknown verb");
+ output.Add("log=Installer policy was asked for an unknown " +
+ "action '" + Sanitize(verb ?? string.Empty) + "'.");
+ return ExitCouldNotRun;
+ }
+ }
+
+ ///
+ /// Emits the pinned identities and the backend argument vector. The
+ /// script holds no URL, digest or backend flag of its own; it asks for
+ /// them here so there is exactly one place a pin can be changed and
+ /// exactly one place a fallback could be introduced.
+ ///
+ private static int EmitPins(List output)
+ {
+ foreach (ViiperPinnedDownload pin in ViiperInstallerPins.All)
+ {
+ string prefix = Key(pin.Component) + ".";
+ output.Add(prefix + "release=" + pin.ReleaseLabel);
+ output.Add(prefix + "filename=" + pin.FileName);
+ output.Add(prefix + "url=" + pin.Url);
+ output.Add(prefix + "sha256=" + pin.Sha256);
+ output.Add(prefix + "size=" +
+ pin.SizeInBytes.ToString(CultureInfo.InvariantCulture));
+ output.Add(prefix + "requireauthenticode=" +
+ (pin.RequireAuthenticode ? "true" : "false"));
+ output.Add(prefix + "signer=" +
+ Sanitize(pin.ExpectedSignerCommonName ?? string.Empty));
+ output.Add("log=Pinned " + Key(pin.Component) + ": " +
+ pin.FileName + " (" + pin.ReleaseLabel + "), SHA-256 " +
+ pin.Sha256 + ", " + (pin.RequireAuthenticode
+ ? "signed by \"" + pin.ExpectedSignerCommonName + "\""
+ : "unsigned upstream") + ".");
+ }
+
+ // The one argument vector that starts the backend, from the same
+ // constant the application spawns with (issue #8). The script must
+ // not spell it out again.
+ output.Add("viiper.serverargs=" +
+ string.Join(" ", ViiperBackendSpawn.ServerArguments));
+ output.Add("viiper.updatenotifyenv=" +
+ ViiperBackendSpawn.UpdateNotifyEnvironmentVariable);
+ output.Add("viiper.updatenotifyvalue=" +
+ ViiperBackendSpawn.UpdateNotifyDisabled);
+ output.Add("log=Backend start arguments: " +
+ string.Join(" ", ViiperBackendSpawn.ServerArguments) +
+ " (the update notifier is disabled on every path that starts " +
+ "the backend).");
+ return ExitDecided;
+ }
+
+ private static int VerifyFile(IReadOnlyList args,
+ List output)
+ {
+ string componentToken = ReadOption(args, "--component");
+ if (!ViiperInstallerPins.TryParseComponent(componentToken,
+ out ViiperInstallerComponent component))
+ {
+ output.Add("error=unknown component");
+ output.Add("log=Installer policy was asked to verify an unknown " +
+ "component '" + Sanitize(componentToken ?? string.Empty) +
+ "'.");
+ return ExitCouldNotRun;
+ }
+
+ string path = ReadOption(args, "--path");
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ output.Add("error=missing path");
+ output.Add("log=Installer policy was asked to verify a file " +
+ "without being told which one.");
+ return ExitCouldNotRun;
+ }
+
+ ViiperPinnedDownload pin = ViiperInstallerPins.For(component);
+ ViiperDownloadObservation observation = Observe(path, pin);
+ ViiperInstallerDecision decision =
+ ViiperInstallerPolicy.DecideDownloadVerification(pin, observation);
+
+ output.Add("verdict=" + decision.Action);
+ output.Add("summary=" + Sanitize(decision.Summary));
+ output.Add("expectedsha256=" + pin.Sha256);
+ output.Add("actualsha256=" + Sanitize(observation.Sha256 ?? string.Empty));
+ output.Add("expectedsigner=" +
+ Sanitize(pin.ExpectedSignerCommonName ?? string.Empty));
+ output.Add("actualsigner=" +
+ Sanitize(observation.SignerCommonName ?? string.Empty));
+ AppendLog(output, decision.Lines);
+
+ return decision.Action == ViiperDownloadVerdict.Approved
+ ? ExitDecided
+ : ExitRefused;
+ }
+
+ ///
+ /// The only I/O in this file's decision path, kept deliberately thin:
+ /// read the length, hash the bytes, and — when the pin requires it —
+ /// ask Windows about the signature. Everything judgemental happens in
+ /// .
+ ///
+ private static ViiperDownloadObservation Observe(string path,
+ ViiperPinnedDownload pin)
+ {
+ FileInfo info;
+ try
+ {
+ info = new FileInfo(path);
+ if (!info.Exists)
+ {
+ return new ViiperDownloadObservation { Exists = false };
+ }
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is UnauthorizedAccessException || ex is ArgumentException ||
+ ex is NotSupportedException)
+ {
+ return new ViiperDownloadObservation
+ {
+ Exists = false,
+ ObservationError = ex.Message,
+ };
+ }
+
+ string digest;
+ try
+ {
+ using FileStream stream = File.OpenRead(path);
+ digest = Convert.ToHexString(SHA256.HashData(stream));
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is UnauthorizedAccessException)
+ {
+ return new ViiperDownloadObservation
+ {
+ Exists = true,
+ SizeInBytes = info.Length,
+ ObservationError = ex.Message,
+ };
+ }
+
+ if (!pin.RequireAuthenticode)
+ {
+ return new ViiperDownloadObservation
+ {
+ Exists = true,
+ SizeInBytes = info.Length,
+ Sha256 = digest,
+ SignatureEvaluated = false,
+ };
+ }
+
+ ViiperSignatureTrust trust =
+ new WinTrustAuthenticodeVerifier().VerifyFile(path);
+ return new ViiperDownloadObservation
+ {
+ Exists = true,
+ SizeInBytes = info.Length,
+ Sha256 = digest,
+ SignatureEvaluated = true,
+ SignatureTrusted = trust.Trusted,
+ SignerCommonName = trust.ObservedSignerCommonName,
+ SignatureDiagnostic = trust.Diagnostic,
+ };
+ }
+
+ private static int DecideUsbip(IReadOnlyList args,
+ List output)
+ {
+ // The gate, not a file version. Read-only: it enumerates driver
+ // packages and verifies catalog trust, and does nothing else.
+ ViiperDriverReadiness readiness =
+ ViiperSetupManager.RefreshDriverReadiness();
+
+ ViiperInstallerDecision decision =
+ ViiperInstallerPolicy.DecideUsbipInstall(
+ readiness.State, readiness.ReleaseLabel, readiness.Tier,
+ ReadOption(args, "--uninstall-version"),
+ ViiperInstallerPins.UsbipWin2,
+ ViiperDriverManifest.ObservedBaselines);
+
+ output.Add("action=" + decision.Action);
+ output.Add("summary=" + Sanitize(decision.Summary));
+ output.Add("readiness=" + readiness.State);
+ output.Add("matchedrelease=" +
+ Sanitize(readiness.ReleaseLabel ?? string.Empty));
+ foreach (string reason in readiness.Reasons)
+ {
+ output.Add("log=Driver gate reason: " + Sanitize(reason));
+ }
+
+ AppendLog(output, decision.Lines);
+ return decision.Action ==
+ ViiperUsbipInstallAction.RefuseUnrecognisedInstall
+ ? ExitRefused
+ : ExitDecided;
+ }
+
+ ///
+ /// Validates the package pair Windows actually bound, after the driver
+ /// step. Runs
+ /// — the same implementation, and the same 0/1/2 exit code, that the
+ /// -viiperdriverdiagnostic switch runs — and reports the verdict
+ /// through the out-file instead of through a console.
+ ///
+ /// Going through the out-file rather than launching
+ /// -viiperdriverdiagnostic as a second process is deliberate.
+ /// That switch prints to an attached parent console and, when there is
+ /// none, opens a modal report window; a setup script that is sometimes
+ /// blocked on a dialog nobody can see is not a verification step.
+ ///
+ private static int ValidateInstalled(List output)
+ {
+ ViiperDriverDiagnosticRun run =
+ ViiperDriverValidationCommand.RunDiagnostic();
+
+ ViiperInstallerDecision decision =
+ ViiperInstallerPolicy.DecidePostInstallValidation(true,
+ run.ExitCode);
+
+ output.Add("verdict=" + decision.Action);
+ output.Add("summary=" + Sanitize(decision.Summary));
+ output.Add("diagnosticexit=" +
+ run.ExitCode.ToString(CultureInfo.InvariantCulture));
+ output.Add("reportpath=" + Sanitize(run.DisplayPath ?? string.Empty));
+ AppendLog(output, decision.Lines);
+ if (!string.IsNullOrWhiteSpace(run.DisplayPath))
+ {
+ output.Add("log=Full driver report saved to " +
+ Sanitize(run.DisplayPath) + ".");
+ }
+
+ return decision.Action == ViiperPostInstallVerdict.Validated
+ ? ExitDecided
+ : ExitRefused;
+ }
+
+ private static int PlanAutostart(IReadOnlyList args,
+ List output)
+ {
+ bool removalRequested = HasFlag(args, "--remove");
+ ViiperAutostartStatus status = ViiperAutostart.Inspect();
+ ViiperInstallerDecision decision =
+ ViiperInstallerPolicy.PlanAutostartRemoval(status,
+ removalRequested);
+
+ output.Add("action=" + decision.Action);
+ output.Add("summary=" + Sanitize(decision.Summary));
+ output.Add("count=" + status.Entries.Count.ToString(
+ CultureInfo.InvariantCulture));
+ AppendLog(output, decision.Lines);
+
+ if (decision.Action == ViiperAutostartPlanAction.Remove)
+ {
+ foreach (string outcome in ViiperAutostart.Remove(status.Entries))
+ {
+ output.Add("log=" + Sanitize(outcome));
+ }
+ }
+
+ return decision.Action == ViiperAutostartPlanAction.CouldNotInspect
+ ? ExitCouldNotRun
+ : ExitDecided;
+ }
+
+ private static void AppendLog(List output,
+ IEnumerable lines)
+ {
+ foreach (string line in lines)
+ {
+ output.Add("log=" + Sanitize(line));
+ }
+ }
+
+ private static string Key(ViiperInstallerComponent component) =>
+ component == ViiperInstallerComponent.UsbipWin2 ? "usbip" : "viiper";
+
+ ///
+ /// The out-file is line-oriented, so a value may not contain a line
+ /// break. Nothing emitted here is user-supplied text, but an exception
+ /// message can be multi-line and would otherwise desynchronise the
+ /// reader.
+ ///
+ private static string Sanitize(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return string.Empty;
+ }
+
+ return value.Replace("\r\n", " ").Replace('\r', ' ')
+ .Replace('\n', ' ').Trim();
+ }
+
+ private static string ReadOption(IReadOnlyList args, string name)
+ {
+ if (args == null)
+ {
+ return null;
+ }
+
+ for (int i = 0; i < args.Count - 1; i++)
+ {
+ if (string.Equals(args[i], name,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return args[i + 1];
+ }
+ }
+
+ return null;
+ }
+
+ private static bool HasFlag(IReadOnlyList args, string name) =>
+ args != null && args.Any(arg =>
+ string.Equals(arg, name, StringComparison.OrdinalIgnoreCase));
+
+ private static bool TryWrite(string path, IEnumerable lines)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ try
+ {
+ string directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllLines(path, lines, new UTF8Encoding(false));
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is UnauthorizedAccessException || ex is ArgumentException ||
+ ex is NotSupportedException)
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
index f041c65..a02e0ac 100644
--- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
+++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
@@ -403,82 +403,75 @@ private static void InstallerProcess_Exited(Process process,
RefreshDriverReadiness();
ViiperPrerequisiteStatus refreshed = GetStatus(
tryStartServer: true);
- if (exitCode == 0 && refreshed.Ready)
+
+ ViiperInstallerPolicy.ViiperInstallerExitReport report =
+ ViiperInstallerPolicy.DescribeInstallerExit(exitCode,
+ refreshed.Ready, InstallLogPath);
+
+ if (report.Succeeded)
{
Interlocked.Exchange(ref promptShownThisSession, 0);
- AppLogger.LogToGui(
- "SUCCESSFUL: VIIPER setup finished successfully. Virtual controllers are ready. Restarting " +
- ProductInfo.ProductName + ".",
- false, false);
- RestartApplication();
+ }
+
+ AppLogger.LogToGui((report.Succeeded ? "SUCCESSFUL: " : string.Empty) +
+ report.Message.Replace("\n", " "), report.IsError, false);
+
+ if (report.RestartApplication && RequestRestart())
+ {
return;
}
- string logPath = Path.Combine(
- Environment.GetFolderPath(
- Environment.SpecialFolder.LocalApplicationData),
- "VIIPER", "install.log");
- string message = exitCode == 0
- ? "VIIPER was installed, but Windows is not reporting every component as ready yet. Restart Windows once, then click Refresh."
- : $"VIIPER setup could not finish (exit code {exitCode}).\n\n" +
- "If a viiper.exe process was still running, it may have blocked the VIIPER registration step. " +
- $"Close viiper.exe manually and run Repair again.\n\nReview the setup log for details:\n{logPath}";
- ShowInstallerMessage(owner, message, "VIIPER setup",
- exitCode == 0 ? MessageBoxImage.Warning :
- MessageBoxImage.Error);
+ if (!report.Succeeded)
+ {
+ ShowInstallerMessage(owner, report.Message, "VIIPER setup",
+ report.IsError ? MessageBoxImage.Error :
+ MessageBoxImage.Warning);
+ }
}));
}
- private static void RestartApplication()
+ /// Where the setup script records every decision it made.
+ public static string InstallLogPath => Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData),
+ "VIIPER", "install.log");
+
+ ///
+ /// Queues the restart and begins shutting down. The replacement is
+ /// started by the shutdown path itself, once the single-instance handle
+ /// is released — see for why
+ /// starting it here instead is issue #12.
+ ///
+ /// False when there was nothing to restart.
+ private static bool RequestRestart()
{
// Global.exelocation, not a composed ".exe" under
// exedirpath: it is the executable actually running, so it survives
// a rename, a portable copy, and the junction/Scoop case that
// exelocation already resolves.
- string exePath = Global.exelocation;
- if (!File.Exists(exePath))
+ if (!PendingApplicationRestart.Current.Request(Global.exelocation))
{
AppLogger.LogToGui("VIIPER setup succeeded, but " +
ProductInfo.ExeBaseName +
".exe was not found for automatic restart.", true, true);
- return;
+ return false;
}
- ThreadPool.QueueUserWorkItem(_ =>
+ try
{
- Thread.Sleep(2000);
-
- try
- {
- ProcessStartInfo startInfo = new ProcessStartInfo
- {
- FileName = exePath,
- UseShellExecute = true,
- };
- Process.Start(startInfo);
- }
- catch (Exception ex)
- {
- AppLogger.LogToGui(
- $"Could not restart {ProductInfo.ProductName} automatically after VIIPER install: {ex.Message}",
- true, true);
- return;
- }
-
- try
- {
- Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
- {
- Application.Current.Shutdown();
- }));
- }
- catch (Exception ex)
+ Application.Current?.Dispatcher?.BeginInvoke(new Action(() =>
{
- AppLogger.LogToGui(
- $"{ProductInfo.ProductName} failed to restart automatically after VIIPER install: {ex.Message}",
- true, true);
- }
- });
+ Application.Current.Shutdown();
+ }));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogToGui(
+ $"{ProductInfo.ProductName} failed to restart automatically after VIIPER install: {ex.Message}",
+ true, true);
+ return false;
+ }
}
private static void ShowInstallerMessage(Window owner, string message,
diff --git a/DS4WindowsTests/PendingApplicationRestartTests.cs b/DS4WindowsTests/PendingApplicationRestartTests.cs
new file mode 100644
index 0000000..e0999d2
--- /dev/null
+++ b/DS4WindowsTests/PendingApplicationRestartTests.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using DS4Windows;
+
+namespace DS4WindowsTests;
+
+///
+/// The ordering fix for
+/// issue #12.
+///
+/// The bug was not that the restart failed loudly — it was that it looked
+/// like it worked. The replacement process started while the original still
+/// held the named single-instance event, saw an instance already running,
+/// signalled it and exited; the original then finished shutting down, taking
+/// the VIIPER backend it owned with it. The user was left with nothing running
+/// after a successful install.
+///
+/// So the tests below are mostly about what must not happen.
+/// The launch is a precondition check, not a best effort: no release, no
+/// launch, and the test proves the starter was never invoked rather than that
+/// it returned something.
+///
+[TestClass]
+public class PendingApplicationRestartTests
+{
+ private const string Executable = @"X:\install\Thrum.exe";
+
+ [TestMethod]
+ public void ARestartIsNotQueuedForAnExecutableThatIsNotThere()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+
+ Assert.IsFalse(restart.Request(Executable, _ => false));
+ Assert.IsFalse(restart.IsRequested);
+ Assert.IsNull(restart.RequestedExecutable);
+ }
+
+ [TestMethod]
+ public void ARestartIsNotQueuedWithoutAPath()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+
+ Assert.IsFalse(restart.Request(null, _ => true));
+ Assert.IsFalse(restart.Request(" ", _ => true));
+ Assert.IsFalse(restart.IsRequested);
+ }
+
+ [TestMethod]
+ public void NothingLaunchesWhenNothingAskedForARestart()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ restart.MarkSingleInstanceReleased();
+
+ List started = new List();
+ Assert.AreEqual(ViiperRestartLaunchOutcome.NotRequested,
+ restart.Launch(started.Add));
+ Assert.AreEqual(0, started.Count);
+ }
+
+ [TestMethod]
+ public void AQueuedRestartRefusesToLaunchWhileTheSingleInstanceHandleIsHeld()
+ {
+ // This is issue #12 itself. Before the fix, this call started the
+ // replacement; the replacement then exited on the guard, and the
+ // shutdown that followed left the machine with no application and no
+ // backend.
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ Assert.IsTrue(restart.Request(Executable, _ => true));
+
+ List started = new List();
+ List log = new List();
+
+ Assert.AreEqual(ViiperRestartLaunchOutcome.SingleInstanceStillHeld,
+ restart.Launch(started.Add, log.Add));
+ Assert.AreEqual(0, started.Count,
+ "the replacement must not be started while the handle is held");
+ Assert.AreEqual(1, log.Count);
+ StringAssert.Contains(log[0], "single-instance handle is still held");
+ }
+
+ [TestMethod]
+ public void TheReplacementStartsOnceTheHandleHasBeenReleased()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ Assert.IsTrue(restart.Request(Executable, _ => true));
+ restart.MarkSingleInstanceReleased();
+
+ List started = new List();
+ Assert.AreEqual(ViiperRestartLaunchOutcome.Launched,
+ restart.Launch(started.Add));
+ CollectionAssert.AreEqual(new[] { Executable }, started);
+ }
+
+ [TestMethod]
+ public void AFailedLaunchAttemptStillCountsAndDoesNotRetryInALoop()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ restart.Request(Executable, _ => true);
+ restart.MarkSingleInstanceReleased();
+
+ List log = new List();
+ Assert.AreEqual(ViiperRestartLaunchOutcome.LaunchFailed,
+ restart.Launch(_ => throw new InvalidOperationException("denied"),
+ log.Add));
+ StringAssert.Contains(log[0], "denied");
+
+ Assert.AreEqual(ViiperRestartLaunchOutcome.AlreadyLaunched,
+ restart.Launch(_ => Assert.Fail("must not start a second time")));
+ }
+
+ [TestMethod]
+ public void ASecondLaunchDoesNotStartASecondInstance()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ restart.Request(Executable, _ => true);
+ restart.MarkSingleInstanceReleased();
+
+ List started = new List();
+ Assert.AreEqual(ViiperRestartLaunchOutcome.Launched,
+ restart.Launch(started.Add));
+ Assert.AreEqual(ViiperRestartLaunchOutcome.AlreadyLaunched,
+ restart.Launch(started.Add));
+ Assert.AreEqual(1, started.Count);
+ }
+
+ [TestMethod]
+ public void ReleasingBeforeTheRequestIsStillAValidOrdering()
+ {
+ // The invariant is "released by the time we launch", not a fixed
+ // sequence of calls; the shutdown path happens to do it the other way
+ // round and either has to be safe.
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ restart.MarkSingleInstanceReleased();
+ restart.Request(Executable, _ => true);
+
+ List started = new List();
+ Assert.AreEqual(ViiperRestartLaunchOutcome.Launched,
+ restart.Launch(started.Add));
+ Assert.AreEqual(1, started.Count);
+ }
+
+ [TestMethod]
+ public void AQueuedRestartRemainsQueuedUntilItLaunches()
+ {
+ PendingApplicationRestart restart = new PendingApplicationRestart();
+ restart.Request(Executable, _ => true);
+ Assert.IsTrue(restart.IsRequested);
+ Assert.AreEqual(Executable, restart.RequestedExecutable);
+
+ restart.MarkSingleInstanceReleased();
+ restart.Launch(_ => { });
+ Assert.IsFalse(restart.IsRequested);
+ }
+
+ [TestMethod]
+ public void TheApplicationWideInstanceStartsIdle()
+ {
+ // A shutdown that nobody asked to restart must not start anything, and
+ // this is the instance the real shutdown path drains.
+ Assert.IsFalse(PendingApplicationRestart.Current.IsRequested);
+ Assert.AreEqual(ViiperRestartLaunchOutcome.NotRequested,
+ PendingApplicationRestart.Current.Launch(
+ _ => Assert.Fail("nothing was queued")));
+ }
+}
diff --git a/DS4WindowsTests/ViiperInstallerPolicyTests.cs b/DS4WindowsTests/ViiperInstallerPolicyTests.cs
new file mode 100644
index 0000000..0648bcf
--- /dev/null
+++ b/DS4WindowsTests/ViiperInstallerPolicyTests.cs
@@ -0,0 +1,684 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using DS4Windows;
+
+namespace DS4WindowsTests;
+
+///
+/// The fail-closed decisions behind extras/install-viiper-backend.ps1.
+///
+/// These live in C# rather than in the script for one reason worth
+/// restating here: the admission rule is "the manifest decides", the manifest is
+/// , and a PowerShell copy of its version
+/// table would be the copy that decides whether a kernel driver is installed.
+/// Keeping the decisions here means they are covered by the suite that already
+/// gates every merge, and the script keeps only the mechanical half — fetching
+/// bytes, running installers, swapping files.
+///
+/// Every test below is a pure call. Nothing here downloads, installs,
+/// elevates, or reads the machine.
+///
+[TestClass]
+public class ViiperInstallerPolicyTests
+{
+ private const string PinnedUsbipDigest =
+ "51620FA5F9F8BE5932BC9D786DEEE557CE06D5407A99CAB490DCFAC71F185FEA";
+
+ private const string PinnedViiperDigest =
+ "3AD872D006DF2FC282E381A68B5A5B3C51E4DA3614D250AB3FDA1C272EF745D0";
+
+ private const string PinnedSigner = "Cloudyne Systems (Scheibling Consulting AB)";
+
+ // ---------------------------------------------------------------- pins --
+
+ [TestMethod]
+ public void ThePinnedUsbipReleaseIsOneTheManifestKnows()
+ {
+ // A pin the manifest does not recognise would install a package the
+ // driver gate then refuses, which is the worst of both designs.
+ Assert.IsTrue(ViiperDriverManifest.ObservedBaselines.Releases.Any(
+ release => release.ReleaseLabel ==
+ ViiperInstallerPins.UsbipWin2.ReleaseLabel));
+ }
+
+ [TestMethod]
+ public void TheUsbipPinCarriesTheInspectedIdentity()
+ {
+ ViiperPinnedDownload pin = ViiperInstallerPins.UsbipWin2;
+ Assert.AreEqual("0.9.7.7", pin.ReleaseLabel);
+ Assert.AreEqual("USBip-0.9.7.7-x64.exe", pin.FileName);
+ Assert.AreEqual(PinnedUsbipDigest, pin.Sha256);
+ Assert.IsTrue(pin.RequireAuthenticode);
+ Assert.AreEqual(PinnedSigner, pin.ExpectedSignerCommonName);
+ StringAssert.StartsWith(pin.Url,
+ "https://github.com/vadimgrn/usbip-win2/releases/download/");
+ }
+
+ [TestMethod]
+ public void TheViiperPinIsAnExactAssetAndNotAReleaseQuery()
+ {
+ ViiperPinnedDownload pin = ViiperInstallerPins.ViiperBackend;
+ Assert.AreEqual("v0.0.5", pin.ReleaseLabel);
+ Assert.AreEqual(PinnedViiperDigest, pin.Sha256);
+ Assert.AreEqual(
+ "https://github.com/hbashton/VIIPER/releases/download/v0.0.5/viiper.exe",
+ pin.Url);
+
+ // Upstream publishes this asset unsigned, so requiring Authenticode
+ // would fail on every honest download. The digest is the whole
+ // identity, and the pin has to say so rather than quietly skip a check.
+ Assert.IsFalse(pin.RequireAuthenticode);
+ Assert.IsNull(pin.ExpectedSignerCommonName);
+ }
+
+ [TestMethod]
+ public void NoPinResolvesAReleaseAtRunTime()
+ {
+ foreach (ViiperPinnedDownload pin in ViiperInstallerPins.All)
+ {
+ StringAssert.Contains(pin.Url, "/releases/download/",
+ pin.FileName + " must name one asset, not a release query.");
+ Assert.IsFalse(pin.Url.Contains("api.github.com"),
+ pin.FileName + " must not be resolved through the releases API.");
+ Assert.IsFalse(pin.Url.Contains("/latest"),
+ pin.FileName + " must never resolve to 'latest'.");
+ Assert.IsFalse(string.IsNullOrWhiteSpace(pin.DigestProvenance),
+ pin.FileName + " must record where its digest came from.");
+ }
+ }
+
+ [TestMethod]
+ public void ADigestComparesRegardlessOfHowItWasWrittenDown()
+ {
+ ViiperPinnedDownload pin = ViiperInstallerPins.UsbipWin2;
+ Assert.IsTrue(pin.MatchesDigest(PinnedUsbipDigest.ToLowerInvariant()));
+ Assert.IsTrue(pin.MatchesDigest("sha256:" + PinnedUsbipDigest));
+ Assert.IsTrue(pin.MatchesDigest(" " + PinnedUsbipDigest + " "));
+ Assert.IsFalse(pin.MatchesDigest(null));
+ Assert.IsFalse(pin.MatchesDigest(string.Empty));
+ }
+
+ [TestMethod]
+ public void AComponentTokenIsParsedOrRejectedButNeverGuessed()
+ {
+ Assert.IsTrue(ViiperInstallerPins.TryParseComponent("usbip",
+ out ViiperInstallerComponent usbip));
+ Assert.AreEqual(ViiperInstallerComponent.UsbipWin2, usbip);
+
+ Assert.IsTrue(ViiperInstallerPins.TryParseComponent("VIIPER",
+ out ViiperInstallerComponent viiper));
+ Assert.AreEqual(ViiperInstallerComponent.ViiperBackend, viiper);
+
+ Assert.IsFalse(ViiperInstallerPins.TryParseComponent("usbipp", out _));
+ Assert.IsFalse(ViiperInstallerPins.TryParseComponent(null, out _));
+ Assert.IsFalse(ViiperInstallerPins.TryParseComponent(" ", out _));
+ }
+
+ // ------------------------------------------------- download verification --
+
+ [TestMethod]
+ public void ACorrectDigestAndTheExpectedSignerIsApproved()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2, GoodUsbipObservation());
+
+ Assert.AreEqual(ViiperDownloadVerdict.Approved, decision.Action);
+ AssertLogged(decision, "SHA-256: expected " + PinnedUsbipDigest +
+ ", actual " + PinnedUsbipDigest + ".");
+ AssertLogged(decision, "Authenticode signer: expected \"" +
+ PinnedSigner + "\", actual \"" + PinnedSigner + "\".");
+ }
+
+ [TestMethod]
+ public void AWrongDigestIsRefusedEvenWithAPerfectSignature()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ With(GoodUsbipObservation(), sha256: new string('A', 64)));
+
+ Assert.AreEqual(ViiperDownloadVerdict.DigestMismatch, decision.Action);
+ AssertLogged(decision, "SHA-256: expected " + PinnedUsbipDigest +
+ ", actual " + new string('A', 64) + ".");
+ }
+
+ [TestMethod]
+ public void AMissingFileIsUnavailableRatherThanAMismatch()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ new ViiperDownloadObservation { Exists = false });
+
+ Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action);
+ AssertLogged(decision, "File present: expected yes, actual no.");
+ }
+
+ [TestMethod]
+ public void ANullObservationIsUnavailableAndNeverApproved()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2, null);
+
+ Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action);
+ }
+
+ [TestMethod]
+ public void AnUncomputableDigestIsUnavailable()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ With(GoodUsbipObservation(), sha256: null,
+ observationError: "the file is in use"));
+
+ Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action);
+ AssertLogged(decision, "Digest could not be computed: the file is in use.");
+ }
+
+ [TestMethod]
+ public void AValidSignatureFromTheWrongPublisherIsRefused()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ With(GoodUsbipObservation(), signerCommonName: "Some Other Publisher"));
+
+ Assert.AreEqual(ViiperDownloadVerdict.UnexpectedSigner, decision.Action);
+ AssertLogged(decision, "Authenticode signer: expected \"" +
+ PinnedSigner + "\", actual \"Some Other Publisher\".");
+ }
+
+ [TestMethod]
+ public void AnUntrustedSignatureIsRefusedBeforeTheSignerIsEvenConsidered()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ With(GoodUsbipObservation(), signatureTrusted: false,
+ signatureDiagnostic: "untrusted root (developer/test signature)"));
+
+ Assert.AreEqual(ViiperDownloadVerdict.SignatureNotTrusted, decision.Action);
+ AssertLogged(decision, "Authenticode chain: expected trusted under " +
+ "normal Windows policy, actual not trusted (untrusted root " +
+ "(developer/test signature)).");
+ Assert.IsFalse(decision.Lines.Any(line =>
+ line.StartsWith("Authenticode signer:", StringComparison.Ordinal)));
+ }
+
+ [TestMethod]
+ public void AnAbsentSignatureIsRefused()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ With(GoodUsbipObservation(), signatureTrusted: false,
+ signerCommonName: null,
+ signatureDiagnostic: "no valid signature"));
+
+ Assert.AreEqual(ViiperDownloadVerdict.SignatureNotTrusted, decision.Action);
+ }
+
+ [TestMethod]
+ public void ASignatureThatWasNeverEvaluatedIsNotAPass()
+ {
+ // The single most dangerous failure shape: a verifier that quietly did
+ // not run reads exactly like one that ran and found nothing wrong.
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2,
+ With(GoodUsbipObservation(), signatureEvaluated: false));
+
+ Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action);
+ AssertLogged(decision,
+ "Authenticode: expected a verified chain, actual (not evaluated).");
+ }
+
+ [TestMethod]
+ public void AnUnsignedComponentPassesOnItsDigestAloneAndSaysSo()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.ViiperBackend,
+ new ViiperDownloadObservation
+ {
+ Exists = true,
+ SizeInBytes = ViiperInstallerPins.ViiperBackend.SizeInBytes,
+ Sha256 = PinnedViiperDigest,
+ SignatureEvaluated = false,
+ });
+
+ Assert.AreEqual(ViiperDownloadVerdict.Approved, decision.Action);
+ Assert.IsTrue(decision.Lines.Any(line => line.Contains(
+ "not required for this component")));
+ }
+
+ [TestMethod]
+ public void AnUnsignedComponentStillFailsOnAWrongDigest()
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.ViiperBackend,
+ new ViiperDownloadObservation
+ {
+ Exists = true,
+ SizeInBytes = 10,
+ Sha256 = new string('B', 64),
+ });
+
+ Assert.AreEqual(ViiperDownloadVerdict.DigestMismatch, decision.Action);
+ }
+
+ [TestMethod]
+ public void EveryVerificationRecordsExpectedBesideActual()
+ {
+ foreach (ViiperDownloadObservation observation in new[]
+ {
+ GoodUsbipObservation(),
+ With(GoodUsbipObservation(), sha256: new string('C', 64)),
+ With(GoodUsbipObservation(), signatureTrusted: false),
+ })
+ {
+ var decision = ViiperInstallerPolicy.DecideDownloadVerification(
+ ViiperInstallerPins.UsbipWin2, observation);
+ Assert.IsTrue(decision.Lines.Any(line =>
+ line.StartsWith("SHA-256: expected ", StringComparison.Ordinal)),
+ "every verification has to record the digest it compared");
+ Assert.IsTrue(decision.Lines.Any(line =>
+ line.StartsWith("Decision: ", StringComparison.Ordinal)),
+ "every verification has to record its outcome");
+ }
+ }
+
+ // --------------------------------------------------- usbip-win2 decision --
+
+ [TestMethod]
+ public void ThePinnedReleaseAlreadyInstalledIsLeftAloneAsAlreadyPinned()
+ {
+ var decision = Decide(ViiperDriverReadinessState.ValidatedExperimental,
+ "0.9.7.7", ViiperDriverTier.ExperimentalBaseline);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.AlreadyPinned, decision.Action);
+ }
+
+ [TestMethod]
+ public void ARecognisedNewerReleaseIsReportedAndNeverDowngraded()
+ {
+ // The maintainer's own machine: 0.9.7.8, which the manifest knows and
+ // which is newer than the release setup would install. Reinstalling
+ // 0.9.7.7 over it would be a kernel-driver downgrade decided by a
+ // script rather than by the person who installed it.
+ var decision = Decide(ViiperDriverReadinessState.ValidatedExperimental,
+ "0.9.7.8", ViiperDriverTier.ExperimentalBaseline);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone,
+ decision.Action);
+ StringAssert.Contains(decision.Summary, "0.9.7.8");
+ StringAssert.Contains(decision.Summary, "experimental baseline");
+ }
+
+ [TestMethod]
+ public void AnUnvalidatedInstallIsRefusedRatherThanRepaired()
+ {
+ var decision = Decide(ViiperDriverReadinessState.DetectedUnvalidated,
+ null, null);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ decision.Action);
+ StringAssert.Contains(decision.Summary, "will not touch");
+ }
+
+ [TestMethod]
+ public void AnEmptyMachineGetsThePinnedRelease()
+ {
+ var decision = Decide(ViiperDriverReadinessState.Missing, null, null);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.InstallPinned, decision.Action);
+ StringAssert.Contains(decision.Summary, "0.9.7.7");
+ }
+
+ [TestMethod]
+ public void ThePinnedReleaseRegisteredButNotBoundIsReinstalled()
+ {
+ // Reboot pending, or a half-finished install. Re-running the same
+ // pinned release is the repair, and it is not a downgrade.
+ var decision = Decide(ViiperDriverReadinessState.Missing, null, null,
+ registered: "0.9.7.7");
+
+ Assert.AreEqual(ViiperUsbipInstallAction.InstallPinned, decision.Action);
+ }
+
+ [TestMethod]
+ public void ADifferentRecognisedReleaseRegisteredButNotBoundIsLeftAlone()
+ {
+ var decision = Decide(ViiperDriverReadinessState.Missing, null, null,
+ registered: "0.9.7.8");
+
+ Assert.AreEqual(ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone,
+ decision.Action);
+ }
+
+ [TestMethod]
+ public void AVersionTheManifestDoesNotKnowIsRefusedNotAdmitted()
+ {
+ foreach (string unlisted in new[] { "0.9.7.9", "1.0.0", "0.9.7.6" })
+ {
+ var decision = Decide(ViiperDriverReadinessState.Missing, null, null,
+ registered: unlisted);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ decision.Action, unlisted + " is not in the manifest");
+ StringAssert.Contains(decision.Summary, unlisted);
+ }
+ }
+
+ [TestMethod]
+ public void ANewerReleaseIsNeverAdmittedForBeingNewer()
+ {
+ // The rule that replaces upstream's "-ge 0.9.7.7" floor. A floor admits
+ // everything above it; the manifest admits exactly what it lists.
+ var decision = Decide(ViiperDriverReadinessState.Missing, null, null,
+ registered: "99.0.0.0");
+
+ Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ decision.Action);
+ }
+
+ [TestMethod]
+ public void AReleaseLabelMatchesRegardlessOfALeadingV()
+ {
+ foreach (string spelling in new[] { "v0.9.7.7", "v.0.9.7.7", " 0.9.7.7 " })
+ {
+ var decision = Decide(ViiperDriverReadinessState.Missing, null, null,
+ registered: spelling);
+ Assert.AreEqual(ViiperUsbipInstallAction.InstallPinned,
+ decision.Action, spelling);
+ }
+ }
+
+ [TestMethod]
+ public void AnApprovedTierMatchIsHandledLikeAnyOtherManifestMatch()
+ {
+ var decision = Decide(ViiperDriverReadinessState.Approved, "0.9.7.7",
+ ViiperDriverTier.Production);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.AlreadyPinned, decision.Action);
+ }
+
+ [TestMethod]
+ public void AnUnknownReadinessValueIsTreatedAsUnverifiable()
+ {
+ var decision = Decide((ViiperDriverReadinessState)99, "0.9.7.7",
+ ViiperDriverTier.ExperimentalBaseline);
+
+ Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall,
+ decision.Action);
+ }
+
+ [TestMethod]
+ public void EveryUsbipDecisionRecordsThePinAndTheObservedState()
+ {
+ foreach (ViiperDriverReadinessState state in
+ Enum.GetValues(typeof(ViiperDriverReadinessState))
+ .Cast())
+ {
+ var decision = Decide(state, "0.9.7.8",
+ ViiperDriverTier.ExperimentalBaseline);
+ Assert.IsTrue(decision.Lines.Any(line =>
+ line.StartsWith("usbip-win2 pinned release: 0.9.7.7",
+ StringComparison.Ordinal)), state.ToString());
+ Assert.IsTrue(decision.Lines.Any(line =>
+ line.StartsWith("usbip-win2 installed state: ",
+ StringComparison.Ordinal)), state.ToString());
+ }
+ }
+
+ // ------------------------------------------------ post-install validation --
+
+ [TestMethod]
+ public void APassingDiagnosticValidatesTheInstalledPair()
+ {
+ var decision = ViiperInstallerPolicy.DecidePostInstallValidation(true,
+ ViiperDriverValidationCommand.ExitCodePassed);
+
+ Assert.AreEqual(ViiperPostInstallVerdict.Validated, decision.Action);
+ }
+
+ [TestMethod]
+ public void AFailingDiagnosticBlocks()
+ {
+ var decision = ViiperInstallerPolicy.DecidePostInstallValidation(true,
+ ViiperDriverValidationCommand.ExitCodeFailed);
+
+ Assert.AreEqual(ViiperPostInstallVerdict.Refused, decision.Action);
+ }
+
+ [TestMethod]
+ public void ADiagnosticThatCouldNotRunIsAFailureAndNotANeutralOutcome()
+ {
+ var decision = ViiperInstallerPolicy.DecidePostInstallValidation(true,
+ ViiperDriverValidationCommand.ExitCodeError);
+
+ Assert.AreEqual(ViiperPostInstallVerdict.CouldNotRun, decision.Action);
+ StringAssert.Contains(decision.Summary, "treated as a failure");
+ }
+
+ [TestMethod]
+ public void ADiagnosticThatNeverStartedIsAlsoAFailure()
+ {
+ // The "the application executable is not next to the script" case.
+ var decision = ViiperInstallerPolicy.DecidePostInstallValidation(false, 0);
+
+ Assert.AreEqual(ViiperPostInstallVerdict.CouldNotRun, decision.Action);
+ }
+
+ [TestMethod]
+ public void AnUndocumentedExitCodeIsNotTrusted()
+ {
+ foreach (int exitCode in new[] { -1, 3, 42 })
+ {
+ var decision = ViiperInstallerPolicy.DecidePostInstallValidation(
+ true, exitCode);
+ Assert.AreEqual(ViiperPostInstallVerdict.CouldNotRun,
+ decision.Action, exitCode.ToString());
+ }
+ }
+
+ // ------------------------------------------------------------ exit codes --
+
+ [TestMethod]
+ public void OnlyAValidatedPairProducesSuccess()
+ {
+ Assert.AreEqual(ViiperInstallerPolicy.ScriptExitSuccess,
+ ViiperInstallerPolicy.ResolveScriptExitCode(
+ ViiperPostInstallVerdict.Validated, restartPending: false));
+ Assert.AreEqual(ViiperInstallerPolicy.ScriptExitSuccess,
+ ViiperInstallerPolicy.ResolveScriptExitCode(
+ ViiperPostInstallVerdict.Validated, restartPending: true));
+ }
+
+ [TestMethod]
+ public void APendingRestartIsItsOwnOutcomeAndNotSuccess()
+ {
+ Assert.AreEqual(ViiperInstallerPolicy.ScriptExitRestartRequired,
+ ViiperInstallerPolicy.ResolveScriptExitCode(
+ ViiperPostInstallVerdict.CouldNotRun, restartPending: true));
+ Assert.AreEqual(ViiperInstallerPolicy.ScriptExitFailed,
+ ViiperInstallerPolicy.ResolveScriptExitCode(
+ ViiperPostInstallVerdict.CouldNotRun, restartPending: false));
+ Assert.AreEqual(ViiperInstallerPolicy.ScriptExitFailed,
+ ViiperInstallerPolicy.ResolveScriptExitCode(
+ ViiperPostInstallVerdict.Refused, restartPending: false));
+ }
+
+ [TestMethod]
+ public void OnlyASuccessfulRunWithAReadyBackendRestartsTheApplication()
+ {
+ var report = ViiperInstallerPolicy.DescribeInstallerExit(
+ ViiperInstallerPolicy.ScriptExitSuccess, ready: true, logPath: null);
+
+ Assert.IsTrue(report.Succeeded);
+ Assert.IsTrue(report.RestartApplication);
+ Assert.IsFalse(report.IsError);
+ }
+
+ [TestMethod]
+ public void ASuccessfulRunWithoutAReadyBackendDoesNotRestart()
+ {
+ var report = ViiperInstallerPolicy.DescribeInstallerExit(
+ ViiperInstallerPolicy.ScriptExitSuccess, ready: false, logPath: null);
+
+ Assert.IsFalse(report.Succeeded);
+ Assert.IsFalse(report.RestartApplication);
+ Assert.IsFalse(report.IsError);
+ }
+
+ [TestMethod]
+ public void ARestartRequiredExitAsksForAWindowsRestartAndIsNotAnError()
+ {
+ var report = ViiperInstallerPolicy.DescribeInstallerExit(
+ ViiperInstallerPolicy.ScriptExitRestartRequired, ready: true,
+ logPath: null);
+
+ Assert.IsFalse(report.Succeeded);
+ Assert.IsFalse(report.RestartApplication);
+ Assert.IsFalse(report.IsError);
+ StringAssert.Contains(report.Message, "Restart Windows");
+ }
+
+ [TestMethod]
+ public void AFailedRunIsAnErrorAndNeverRestartsTheApplication()
+ {
+ foreach (int exitCode in new[] { 1, 2, 7 })
+ {
+ var report = ViiperInstallerPolicy.DescribeInstallerExit(exitCode,
+ ready: true, logPath: @"%LOCALAPPDATA%\VIIPER\install.log");
+
+ Assert.IsFalse(report.Succeeded, exitCode.ToString());
+ Assert.IsFalse(report.RestartApplication, exitCode.ToString());
+ Assert.IsTrue(report.IsError, exitCode.ToString());
+ StringAssert.Contains(report.Message, "install.log");
+ }
+ }
+
+ [TestMethod]
+ public void AReadyBackendNeverTurnsAFailedRunIntoASuccess()
+ {
+ // Setup can fail after the backend is already answering — a refused
+ // driver package, for instance. The backend being up is not the
+ // question the exit code answers.
+ var report = ViiperInstallerPolicy.DescribeInstallerExit(
+ ViiperInstallerPolicy.ScriptExitFailed, ready: true, logPath: null);
+
+ Assert.IsFalse(report.Succeeded);
+ }
+
+ // -------------------------------------------------------------- autostart --
+
+ [TestMethod]
+ public void NoAutostartEntryMeansNothingToDoAndSetupCreatesNone()
+ {
+ var decision = ViiperInstallerPolicy.PlanAutostartRemoval(
+ new ViiperAutostartStatus(Array.Empty()),
+ removalRequested: false);
+
+ Assert.AreEqual(ViiperAutostartPlanAction.NothingToDo, decision.Action);
+ Assert.IsTrue(decision.Lines.Any(line => line.Contains(
+ "Setup does not create any")));
+ }
+
+ [TestMethod]
+ public void AnExistingEntryIsOfferedForRemovalAndNotSilentlyAdopted()
+ {
+ var decision = ViiperInstallerPolicy.PlanAutostartRemoval(
+ StatusWithBothEntries(), removalRequested: false);
+
+ Assert.AreEqual(ViiperAutostartPlanAction.OfferRemoval, decision.Action);
+ Assert.IsTrue(decision.Lines.Any(line => line.Contains(
+ "Startup registry entry \"VIIPER\"")));
+ Assert.IsTrue(decision.Lines.Any(line => line.Contains(
+ "Logon scheduled task \"RunVIIPER\"")));
+ Assert.IsTrue(decision.Lines.Any(line => line.Contains(
+ "--update-notify none")),
+ "the reason these entries matter is the live self-updater");
+ }
+
+ [TestMethod]
+ public void RemovalHappensOnlyWhenItWasAskedFor()
+ {
+ var decision = ViiperInstallerPolicy.PlanAutostartRemoval(
+ StatusWithBothEntries(), removalRequested: true);
+
+ Assert.AreEqual(ViiperAutostartPlanAction.Remove, decision.Action);
+ StringAssert.Contains(decision.Summary, "2 existing");
+ }
+
+ [TestMethod]
+ public void AnUnreadableAutostartStateIsNeverReportedAsAbsent()
+ {
+ foreach (ViiperAutostartStatus status in new[]
+ {
+ null,
+ new ViiperAutostartStatus(Array.Empty(),
+ "registry: access denied"),
+ })
+ {
+ var decision = ViiperInstallerPolicy.PlanAutostartRemoval(status,
+ removalRequested: true);
+ Assert.AreEqual(ViiperAutostartPlanAction.CouldNotInspect,
+ decision.Action);
+ }
+ }
+
+ // ----------------------------------------------------------------- helpers --
+
+ private static ViiperInstallerDecision Decide(
+ ViiperDriverReadinessState state, string matchedRelease,
+ ViiperDriverTier? tier, string registered = null) =>
+ ViiperInstallerPolicy.DecideUsbipInstall(state, matchedRelease, tier,
+ registered, ViiperInstallerPins.UsbipWin2,
+ ViiperDriverManifest.ObservedBaselines);
+
+ private static ViiperDownloadObservation GoodUsbipObservation() =>
+ new ViiperDownloadObservation
+ {
+ Exists = true,
+ SizeInBytes = ViiperInstallerPins.UsbipWin2.SizeInBytes,
+ Sha256 = PinnedUsbipDigest,
+ SignatureEvaluated = true,
+ SignatureTrusted = true,
+ SignerCommonName = PinnedSigner,
+ SignatureDiagnostic = "trusted",
+ };
+
+ private static ViiperDownloadObservation With(
+ ViiperDownloadObservation source, string sha256 = "\0",
+ bool? signatureEvaluated = null, bool? signatureTrusted = null,
+ string signerCommonName = "\0", string signatureDiagnostic = "\0",
+ string observationError = "\0") =>
+ new ViiperDownloadObservation
+ {
+ Exists = source.Exists,
+ SizeInBytes = source.SizeInBytes,
+ Sha256 = sha256 == "\0" ? source.Sha256 : sha256,
+ SignatureEvaluated = signatureEvaluated ?? source.SignatureEvaluated,
+ SignatureTrusted = signatureTrusted ?? source.SignatureTrusted,
+ SignerCommonName = signerCommonName == "\0"
+ ? source.SignerCommonName : signerCommonName,
+ SignatureDiagnostic = signatureDiagnostic == "\0"
+ ? source.SignatureDiagnostic : signatureDiagnostic,
+ ObservationError = observationError == "\0"
+ ? source.ObservationError : observationError,
+ };
+
+ private static ViiperAutostartStatus StatusWithBothEntries() =>
+ new ViiperAutostartStatus(new List
+ {
+ new ViiperAutostartEntry(ViiperAutostartKind.RegistryRunValue,
+ "VIIPER", @"C:\viiper\viiper.exe server"),
+ new ViiperAutostartEntry(ViiperAutostartKind.ScheduledTask,
+ "RunVIIPER", @"C:\viiper\viiper.exe server"),
+ });
+
+ private static void AssertLogged(ViiperInstallerDecision decision,
+ string expected)
+ {
+ Assert.IsTrue(decision.Lines.Any(line =>
+ string.Equals(line, expected, StringComparison.Ordinal)),
+ "expected the decision to record: " + expected +
+ Environment.NewLine + "actual lines:" + Environment.NewLine +
+ string.Join(Environment.NewLine, decision.Lines));
+ }
+}
diff --git a/DS4WindowsTests/ViiperInstallerScriptTests.cs b/DS4WindowsTests/ViiperInstallerScriptTests.cs
new file mode 100644
index 0000000..ad342b8
--- /dev/null
+++ b/DS4WindowsTests/ViiperInstallerScriptTests.cs
@@ -0,0 +1,215 @@
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using DS4Windows;
+
+namespace DS4WindowsTests;
+
+///
+/// Structural guards on extras/install-viiper-backend.ps1.
+///
+/// These are a backstop, and they are the weakest tests in this change
+/// set. Matching text in a script proves the text is there, not that the
+/// script behaves. The real coverage of the fail-closed logic is
+/// , which exercises the actual
+/// decision functions; the script was deliberately reduced to orchestration so
+/// that almost nothing decidable is left in it.
+///
+/// What is left in it is four properties that cannot be expressed
+/// anywhere else, because they are properties of the absence of code: that no
+/// autostart entry is created, that no backend is started without the update
+/// notifier disabled, that no URL or digest is written down outside the pins,
+/// and that the rollback backup is not deleted on success. A regression in any
+/// of those is silent — the script keeps working, it just stops being safe —
+/// so a crude check that fails loudly is worth more than no check at all.
+///
+/// Running the script itself is [VM]-gated: it installs a kernel driver.
+/// See the plan's Part 3 rule 1.
+///
+[TestClass]
+public class ViiperInstallerScriptTests
+{
+ private static string script;
+
+ [ClassInitialize]
+ public static void LoadScript(TestContext context)
+ {
+ string path = FindScript();
+ Assert.IsNotNull(path,
+ "extras/install-viiper-backend.ps1 was not found above " +
+ AppContext.BaseDirectory + ".");
+ script = File.ReadAllText(path);
+ }
+
+ [TestMethod]
+ public void TheScriptLooksForTheExecutableThisBuildActuallyProduces()
+ {
+ // The script cannot read a C# constant, so this coupling is the one
+ // place ProductInfo and the script have to be kept in step by hand.
+ StringAssert.Contains(script,
+ "$script:DefaultAppExecutableName = \"" +
+ ProductInfo.ExeBaseName + ".exe\"");
+ }
+
+ [TestMethod]
+ public void TheScriptCreatesNoAutostartEntry()
+ {
+ // Both mechanisms, gone: the RunVIIPER logon task and the HKCU Run
+ // value that "viiper.exe install" writes. Thrum starts the backend when
+ // a profile needs it and stops it on exit (plan task 2.4b), so a logon
+ // entry would start a backend the application never owns and never
+ // stops.
+ foreach (string forbidden in new[]
+ {
+ "Register-ScheduledTask",
+ "New-ScheduledTaskAction",
+ "New-ScheduledTaskTrigger",
+ "New-ScheduledTaskPrincipal",
+ "schtasks",
+ "ArgumentList \"install\"",
+ "New-ItemProperty",
+ "Set-ItemProperty",
+ "CurrentVersion\\Run\\",
+ })
+ {
+ Assert.IsFalse(script.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
+ "the setup script must not contain '" + forbidden + "'.");
+ }
+ }
+
+ [TestMethod]
+ public void TheTaskNameAppearsOnlyWhereRemovalIsDocumented()
+ {
+ // The name is allowed to be written down — the script has to be able to
+ // say what -RemoveViiperAutostart removes. It is not allowed to appear
+ // anywhere a command could act on it, which the creation-verb ban above
+ // covers; this pins the remaining occurrence so a registration cannot
+ // be reintroduced under cover of the documentation.
+ MatchCollection matches = Regex.Matches(script, ".*RunVIIPER.*");
+ Assert.AreEqual(1, matches.Count,
+ "RunVIIPER may appear once, in the -RemoveViiperAutostart help.");
+ StringAssert.Contains(matches[0].Value, "task). Without this");
+ }
+
+ [TestMethod]
+ public void TheScriptOffersToRemoveAnAutostartEntryItDidNotCreate()
+ {
+ StringAssert.Contains(script, "[switch]$RemoveViiperAutostart");
+ StringAssert.Contains(script, "$autostartArgs += \"--remove\"");
+ }
+
+ [TestMethod]
+ public void EveryBackendStartDisablesTheUpdateNotifier()
+ {
+ // Issue #8's remaining half. The argument vector comes from the same
+ // constant the application spawns with, so the script cannot drift from
+ // it, and a literal "server" would be exactly that drift.
+ StringAssert.Contains(script, "-ArgumentList $serverArgs");
+ Assert.IsFalse(script.Contains("-ArgumentList \"server\"",
+ StringComparison.Ordinal),
+ "the backend must never be started with a hand-written argument list.");
+ StringAssert.Contains(script, "$pins['viiper.serverargs']");
+ }
+
+ [TestMethod]
+ public void TheScriptResolvesNoReleaseAndHardCodesNoArtefactIdentity()
+ {
+ foreach (string forbidden in new[]
+ {
+ "api.github.com",
+ "github.com",
+ "per_page",
+ "browser_download_url",
+ "-ge $requiredUsbipVersion",
+ })
+ {
+ Assert.IsFalse(script.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
+ "the setup script must not contain '" + forbidden +
+ "': URLs, digests and admissible versions come from the pins.");
+ }
+ }
+
+ [TestMethod]
+ public void NothingIsExecutedBeforeItHasBeenVerified()
+ {
+ int verified = script.IndexOf("Get-VerifiedPinnedFile \"usbip\"",
+ StringComparison.Ordinal);
+ int executed = script.IndexOf("-ArgumentList \"/S\"",
+ StringComparison.Ordinal);
+
+ Assert.IsTrue(verified >= 0, "the driver installer must be verified.");
+ Assert.IsTrue(executed >= 0, "the driver installer is still run silently.");
+ Assert.IsTrue(verified < executed,
+ "the pinned installer must be verified before it is executed.");
+
+ Assert.AreEqual(1, Regex.Matches(script,
+ Regex.Escape("-ArgumentList \"/S\"")).Count,
+ "there must be exactly one place the driver installer is run.");
+ }
+
+ [TestMethod]
+ public void TheRollbackBackupSurvivesASuccessfulInstall()
+ {
+ // Upstream deletes viiper.exe.previous once the API answers, which
+ // leaves rollback available only inside the install window. The failure
+ // this protects against is a backend that installs cleanly and then
+ // misbehaves.
+ Assert.IsFalse(Regex.IsMatch(script,
+ @"Remove-Item\s+-LiteralPath\s+\$backupPath"),
+ "the .previous backup must be kept after a successful install.");
+ StringAssert.Contains(script, "was kept at $backupPath for rollback");
+ }
+
+ [TestMethod]
+ public void AMissingOrUnusableVerificationHelperStopsSetup()
+ {
+ // Fail-closed: no helper, no verification, no install. Silence is never
+ // read as approval.
+ StringAssert.Contains(script, "rather than installing anything unverified");
+ StringAssert.Contains(script, "produced no result");
+ StringAssert.Contains(script, "does not match its exit code");
+ }
+
+ [TestMethod]
+ public void AnUnrecognisedPolicyActionStopsSetup()
+ {
+ StringAssert.Contains(script, "unrecognised usbip-win2 ");
+ StringAssert.Contains(script, "rather than guessing");
+ }
+
+ [TestMethod]
+ public void AStagedFileReplacesTheDownloadAndNothingElse()
+ {
+ // The VM run sheet's negative cases stage a corrupted or wrongly-signed
+ // artefact. That has to travel the real path, so the staged file is
+ // handed to the same verifier by the same call — there is no second,
+ // laxer branch for it.
+ StringAssert.Contains(script, "[string]$UsbipInstallerFile");
+ StringAssert.Contains(script, "[string]$ViiperBackendFile");
+ Assert.AreEqual(2, Regex.Matches(script,
+ Regex.Escape("Get-VerifiedPinnedFile \"")).Count,
+ "both components must be fetched through the verifying helper.");
+ Assert.AreEqual(1, Regex.Matches(script,
+ @"\$verification\s*=\s*Invoke-InstallerPolicy").Count,
+ "there must be exactly one verification call site, shared by the " +
+ "download path and the staged-file path.");
+ }
+
+ private static string FindScript()
+ {
+ DirectoryInfo directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ string candidate = Path.Combine(directory.FullName, "extras",
+ "install-viiper-backend.ps1");
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ directory = directory.Parent;
+ }
+
+ return null;
+ }
+}
diff --git a/docs/dev/PLAN-PROGRESS.md b/docs/dev/PLAN-PROGRESS.md
index 1e9a633..6b017ac 100644
--- a/docs/dev/PLAN-PROGRESS.md
+++ b/docs/dev/PLAN-PROGRESS.md
@@ -2848,3 +2848,186 @@ No new key, so the neutral-only policy for new keys does not apply here.
Suite: **696 passed / 0 failed** (CI filter), unchanged from baseline — the pass
is itself the check, since the key is listed as XAML-reachable with no
known-missing entry, so a null lookup would fail the test.
+
+---
+
+## 2026-07-26 — Phase 2.4: installer hardening (pins, verification, autostart removal, issue #12)
+
+**Session scope:** plan task **2.4**, written on top of the upstream merge
+analysed in `upstream-delta-2026-07-26.md`. Nothing in this session installed,
+upgraded, removed or touched a driver, service, scheduled task, registry Run
+value or the VIIPER install directory. The setup script was never executed; it
+was parsed (AST only), and the decision layer it consults was exercised
+directly.
+
+### The shape of the change, and why
+
+Upstream's four commits left four of the seven 2.4 requirements entirely ours,
+and the honest reading of the remaining three is that the script was making
+security decisions it had no way to make well: a `-ge 0.9.7.7` floor against a
+version probe that reads the FileVersion of `usbip2_ude.sys` — a DriverVer such
+as `1.45.29.368`, which is not the release label and compares greater than every
+floor anyone would write. The floor passed trivially on every install it has
+ever seen, including this machine's.
+
+So the decisions moved into C# and the script kept the mechanical half. The
+deciding argument is not "C# is nicer to test": it is that the admission rule is
+*the manifest decides*, the manifest is `ViiperDriverManifest`, and its own
+contract says it must not be duplicated into the UI, the broker or the
+installer. A PowerShell copy of the version table would have been exactly that
+duplicate — and it would have been the copy deciding whether a kernel driver
+gets installed.
+
+New surface:
+
+| File | What it owns |
+|---|---|
+| `Viiper/Validation/ViiperInstallerPins.cs` | The two exact artefacts setup may fetch: URL, SHA-256, size, whether Authenticode is required and from whom, and **how the digest was obtained**. |
+| `Viiper/Validation/ViiperInstallerPolicy.cs` | Pure, total decisions: download verdict, usbip install action, post-install verdict, script exit code, the app-side reading of that exit code, autostart plan. |
+| `Viiper/Validation/ViiperInstallerPolicyCommand.cs` | The read-only `-viiperinstallerpolicy` verb surface the script consults. |
+| `DS4Control/PendingApplicationRestart.cs` | Issue #12's ordering, enforced rather than commented. |
+
+### Testing approach, and why it is not Pester
+
+The brief offered (a) dot-sourceable script functions plus a Pester suite in CI,
+or (b) decisions in testable C# with the script as thin orchestration, and asked
+for whichever puts the fail-closed logic under real tests.
+
+(b), for three reasons. The manifest argument above is the first and decisive
+one. Second, MSTest already gates every merge here; a Pester job would be a
+second harness, a second runner dependency and a second place a filter can go
+stale, bought for logic that would still have to reach into C# for the version
+table. Third — and this is the part worth stating plainly — the *shape* of (b)
+is what makes the properties testable at all: `DecideDownloadVerification` is a
+function from observed facts to a verdict, so "valid signature, unexpected
+subject" is three lines of test instead of a signing fixture.
+
+67 new tests, all pure:
+
+- `ViiperInstallerPolicyTests` (46) — correct digest; wrong digest; missing
+ file; null observation; uncomputable digest; valid signature with an
+ unexpected subject; untrusted signature; absent signature; **a signature that
+ was never evaluated** (the failure shape that reads exactly like a pass);
+ unsigned component approved on its digest alone and refused on a wrong one;
+ version not in the manifest (three spellings); version newer than pinned;
+ already-installed pinned version; already-installed recognised-but-different
+ version; registered-but-not-bound in all four flavours; unknown enum value;
+ post-install 0/1/2, an undocumented code, and never-started; every exit-code
+ mapping in both directions; the autostart plan including an unreadable state.
+- `PendingApplicationRestartTests` (10) — the #12 ordering, below.
+- `ViiperInstallerScriptTests` (11) — **the weakest tests here, and labelled as
+ such in the file.** Matching text in a script proves the text is there. They
+ exist for the four properties that are properties of *absent* code — no
+ autostart creation, no backend start without the update flag, no URL or digest
+ outside the pins, no deletion of the rollback backup — where a regression is
+ silent: the script keeps working, it just stops being safe.
+
+### Requirement by requirement
+
+1. **Pinned + digest + Authenticode before execution.** `Get-VerifiedPinnedFile`
+ is the only way an artefact reaches disk, and its next statement is the
+ verification call. Refusal deletes the file and throws. Verified live against
+ the genuine signed installer: digest and subject both matched and were logged
+ expected-beside-actual; a one-byte-flipped copy was refused with both digests
+ in the log.
+2. **Post-install validation of the pair.** A `validate-installed` verb runs
+ `ViiperDriverValidationCommand.RunDiagnostic()` — the same implementation and
+ the same 0/1/2 the `-viiperdriverdiagnostic` switch runs — and the script
+ branches on it. Deliberately not launched as `-viiperdriverdiagnostic` in a
+ second process: that switch prints to an attached parent console and, when
+ there is none, opens a **modal report window**. A setup step that can block
+ on a dialog nobody can see is not a verification step. Exit 2 and "could not
+ run at all" are both failures.
+3. **No silent acceptance of an unlisted release.** The floor is gone; the
+ primary input is the gate's four-state answer, not a file version. Verified
+ live on this machine: `readiness=ValidatedExperimental`,
+ `matchedrelease=0.9.7.8`, `action=LeaveRecognisedReleaseAlone` —
+ *"It is a release this build recognises as an experimental baseline, and it
+ is left exactly as it is."* Nothing was installed over it and no downgrade
+ was attempted. VIIPER is pinned to an exact asset by version **and** digest;
+ `Get-GithubReleaseAsset` and the newest-non-draft walk are deleted.
+4. **Atomic install, rollback retained.** `.previous` is no longer deleted on
+ success, and its path is logged. Rollback that exists only inside the install
+ window is not rollback — the failure it guards against is a backend that
+ installs cleanly and then misbehaves.
+5. **Log every decision.** Every decision function returns its audit lines
+ together with its verdict, from the same call, so the two cannot disagree.
+ The script copies every `log=` line into `install.log` verbatim.
+6. **Both autostart mechanisms removed.** `viiper.exe install` and
+ `Register-ViiperRunTask` are gone, and with them the `$registrationSafeToRun`
+ dance they were load-bearing for. `Stop-ViiperProcesses` survives, now needed
+ only by the atomic install, keeping upstream's retry/escalate/fail-closed
+ behaviour verbatim. A pre-existing entry is detected through 2.4b's read-only
+ detector, reported, and removed only with `-RemoveViiperAutostart` or the
+ Settings button — never adopted.
+7. **Issue #8 closed on every path.** `Start-AndVerifyViiper` takes its argument
+ vector from `ViiperBackendSpawn.ServerArguments` (via the `pins` verb) rather
+ than spelling out `server`, so the script cannot drift from the application,
+ and it sets `VIIPER_UPDATE_NOTIFY` as well. With both autostart entries gone,
+ no path remains that starts an update-nagging backend.
+8. **Issue #12 fixed.** Below.
+
+### Issue #12: the ordering, and what happens to the backend
+
+`RestartApplication` no longer starts anything. It records intent;
+`CleanShutdown` starts the replacement **after** `threadComEvent.Close()`, and
+`PendingApplicationRestart.Launch` refuses outright until
+`MarkSingleInstanceReleased()` has been called. The ordering is a precondition,
+not a comment: an edit that moves the launch earlier fails a test that says why.
+
+**The backend across the restart is deliberately not special-cased.**
+Stop-on-exit runs as usual, the owned backend goes down with the app, and the
+new instance starts a fresh one on demand. The alternative — exempting an
+install-driven restart — would leave a backend running that the new instance
+does not own and would therefore never stop, turning a temporary special case
+into a permanent orphan. A few hundred milliseconds of downtime during a restart
+nobody is playing through is the cheaper side of that trade.
+
+### Verification
+
+- `dotnet build DS4WindowsWPF.sln -c Release -p:Platform=x64` — **0 errors**, 14
+ pre-existing warnings.
+- Full suite with the CI filter — **763 passed / 0 failed**, from the 696
+ baseline (+67). `AppSettingsTests.CheckSettingsSave` remains excluded and
+ remains stale for the reason recorded in the 2.4b entry.
+- Script **parsed** with `[Parser]::ParseFile` — 0 errors, 2,609 tokens, five
+ declared parameters. Parsing is not execution; the script was never run.
+- **Live read-only pass** of every policy verb against the packaged build, using
+ the genuine artefacts already retained in the workspace. Results as quoted
+ above, plus: the pinned VIIPER asset approved on its digest with the
+ "unsigned upstream" line; a missing file reported as `Unavailable` rather than
+ as a mismatch; `validate-installed` returning `Validated`; `autostart`
+ returning `NothingToDo` with count 0, matching this machine's known state.
+
+### Deviations
+
+1. **Post-install validation goes through `validate-installed`, not a second
+ `-viiperdriverdiagnostic` process.** Same implementation, same exit codes, no
+ modal-dialog hazard. Recorded because the brief named the switch.
+2. **VIIPER is pinned to the public v0.0.5 asset, not to a bundled copy.** The
+ plan allowed bundling ours until hbashton/VIIPER#3 lands. Unnecessary: the
+ public asset has a stable digest that two independent sources agree on, and
+ pinning by digest already immunises us against the mis-stamp. The mis-stamped
+ embedded version is recorded in the pin so nothing ever validates by it.
+ Bundling stays available for Phase 5.2 through `-ViiperBackendFile`.
+3. **`-UsbipInstallerFile` / `-ViiperBackendFile` added.** Not in the brief.
+ They let the VM run sheet's negative cases travel the real code path rather
+ than a parallel one, and they are not a bypass: a staged file replaces the
+ download and nothing else, verified by the same call against the same pin.
+4. **A third exit code (3) exists.** "Installed, but the pair cannot be
+ validated until Windows restarts" is neither success nor failure, and
+ collapsing it into either would have meant lying in one direction.
+5. **Not verified, and cannot be here:** the script end to end. Running it
+ installs a kernel driver, which Part 3 rule 1 puts behind a TESTENV
+ checkpoint. The decision layer it consults is verified live; the
+ orchestration around it is source-level only.
+
+### For the VM run sheet
+
+`PHASE2-VM-VALIDATION-PREP-20260726.md` Phase B needs no change to be runnable,
+and gains a cheaper route: B1 and B2 can be done **without installing anything**
+by calling `Thrum.exe -viiperinstallerpolicy verify-file --component usbip
+--path --out ` directly — that is the exact call the script
+gates on. To exercise them through the script instead, pass
+`-UsbipInstallerFile `. B4's "no autostart was created" assertion is now
+also covered by a unit test, but the live enumeration is still worth capturing.
diff --git a/extras/install-viiper-backend.ps1 b/extras/install-viiper-backend.ps1
index 00bbd96..539aefc 100644
--- a/extras/install-viiper-backend.ps1
+++ b/extras/install-viiper-backend.ps1
@@ -1,16 +1,80 @@
+<#
+.SYNOPSIS
+ Installs or repairs the VIIPER backend, and — only on explicit terms — the
+ usbip-win2 kernel driver it depends on.
+
+.DESCRIPTION
+ Every decision that can end with something being executed, installed or
+ replaced is made by the application, not by this script. The script fetches
+ bytes, runs installers and swaps files; Thrum's installer policy decides
+ whether it may. That split exists because the release manifest, the pinned
+ digests and the driver gate already live in the application, are covered by
+ its test suite, and must not be duplicated into a copy that then gets to
+ decide whether a kernel driver is installed.
+
+ Consequences worth stating plainly:
+
+ * Nothing downloaded is executed before its SHA-256 — and, where the
+ publisher signs, its Authenticode chain and signer — have been checked
+ against a pinned identity.
+ * Nothing newer is accepted just because it is newer. A usbip-win2 release
+ this build does not recognise is left exactly as it is, and setup says
+ so rather than "repairing" it.
+ * The package pair Windows actually bound is validated after the driver
+ step, through the same gate as the -viiperdriverdiagnostic switch.
+ * No autostart entry is created. Thrum starts the backend when a profile
+ needs it and stops it on exit. A pre-existing entry is reported, never
+ adopted, and removed only when asked.
+ * Every backend this script starts is started with the update notifier
+ disabled.
+
+.PARAMETER NoPause
+ Do not wait for a key press at the end. Passed by Thrum.
+
+.PARAMETER RemoveViiperAutostart
+ Remove any pre-existing VIIPER logon entry (the HKCU Run value and/or the
+ RunVIIPER task). Without this, an existing entry is only reported.
+
+.PARAMETER AppExecutable
+ Full path to the application executable that provides the installer policy.
+ Defaults to the executable next to this script's parent folder. Setup
+ refuses to continue without it: it is what performs every verification.
+
+.PARAMETER UsbipInstallerFile
+ Use an already-downloaded usbip-win2 installer instead of fetching it. The
+ file is verified against the pin exactly as a download would be, so this is
+ an offline convenience and a test hook, never a way past a check.
+
+.PARAMETER ViiperBackendFile
+ The same, for the VIIPER backend executable.
+
+.NOTES
+ Exit codes: 0 success (driver pair validated), 1 refused or failed,
+ 3 installed but validation deferred until Windows restarts.
+#>
param(
- [switch]$NoPause
+ [switch]$NoPause,
+ [switch]$RemoveViiperAutostart,
+ [string]$AppExecutable,
+ [string]$UsbipInstallerFile,
+ [string]$ViiperBackendFile
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$script:ExitCode = 0
$script:RebootRecommended = $false
+$script:DriverValidated = $false
+$script:Refused = $false
$script:InstallDir = Join-Path $env:LOCALAPPDATA "VIIPER"
$script:LogPath = Join-Path $script:InstallDir "install.log"
$script:TempDir = Join-Path ([IO.Path]::GetTempPath()) (
"Thrum-VIIPER-Setup-" + [Guid]::NewGuid().ToString("N"))
+# Kept in step with ProductInfo.ExeBaseName by a guard test; this script cannot
+# read a C# constant, and the executable is what makes every check possible.
+$script:DefaultAppExecutableName = "Thrum.exe"
+
function Write-SetupLog([string]$message, [ConsoleColor]$color =
[ConsoleColor]::Gray) {
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
@@ -34,18 +98,45 @@ function Test-Administrator {
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
-function Get-UsbipInstalledVersion {
- $driverPath = Join-Path $env:SystemRoot "System32\drivers\usbip2_ude.sys"
- if (Test-Path -LiteralPath $driverPath) {
- try {
- $versionText = (Get-Item -LiteralPath $driverPath).
- VersionInfo.FileVersion
- $version = ConvertTo-VersionFromObject $versionText
- if ($version) { return $version }
+function ConvertTo-VersionFromObject([object]$value) {
+ if ($null -eq $value) { return $null }
+ if ($value -is [Version]) { return $value }
+
+ try {
+ if ($value -is [string]) {
+ $text = $value.Trim()
+ }
+ else {
+ $text = [string]$value
+ if ($null -eq $text) { return $null }
+ $text = $text.Trim()
}
- catch { }
}
+ catch { return $null }
+
+ if ($text.Length -eq 0) { return $null }
+
+ $parsed = $null
+ if ([Version]::TryParse($text, [ref]$parsed)) {
+ return $parsed
+ }
+
+ return $null
+}
+<#
+ The only usbip-win2 probe this script still performs, and it answers one
+ narrow question: does an uninstall entry claim a release label?
+
+ It is deliberately not used to decide anything. The release label is a hint
+ that something is registered even when no packages are bound; the identity
+ decision belongs to the driver gate, which reads the packages Windows
+ actually loaded. Reading usbip2_ude.sys's FileVersion, as this script used
+ to, answers neither question: that file carries a DriverVer such as
+ 1.45.29.368, which is not the 0.9.7.x release label and compares greater
+ than every floor anyone would write.
+#>
+function Get-UsbipRegisteredRelease {
foreach ($root in @(
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
@@ -60,37 +151,82 @@ function Get-UsbipInstalledVersion {
Select-Object -First 1
if ($entry -and $entry.DisplayVersion) {
$version = ConvertTo-VersionFromObject $entry.DisplayVersion
- if ($version) { return $version }
+ if ($version) { return $version.ToString() }
+ return ([string]$entry.DisplayVersion).Trim()
}
}
- return $null
+ return ""
}
-function ConvertTo-VersionFromObject([object]$value) {
- if ($null -eq $value) { return $null }
- if ($value -is [Version]) { return $value }
-
- try {
- if ($value -is [string]) {
- $text = $value.Trim()
- }
- else {
- $text = [string]$value
- if ($null -eq $text) { return $null }
- $text = $text.Trim()
- }
+function Resolve-AppExecutable([string]$explicitPath) {
+ if ($explicitPath) {
+ if (Test-Path -LiteralPath $explicitPath) { return $explicitPath }
+ throw "The application executable was not found at '$explicitPath'."
}
- catch { return $null }
- if ($text.Length -eq 0) { return $null }
+ $candidate = Join-Path (Split-Path -Parent $PSScriptRoot) `
+ $script:DefaultAppExecutableName
+ if (Test-Path -LiteralPath $candidate) { return $candidate }
- $parsed = $null
- if ([Version]::TryParse($text, [ref]$parsed)) {
- return $parsed
- }
+ throw (
+ "Setup could not find $($script:DefaultAppExecutableName) next to the " +
+ "'extras' folder. That executable performs every digest, signature and " +
+ "driver-package check this script depends on, so setup stops here " +
+ "rather than installing anything unverified. Run setup from inside the " +
+ "application, or pass -AppExecutable.")
+}
- return $null
+<#
+ Runs one installer-policy verb and returns its exit code plus the key/value
+ result. Fail-closed at every step: a missing helper, a missing result file,
+ an unreadable result, or a result whose reported exit code disagrees with
+ the process exit code all throw. Setup never proceeds on silence.
+#>
+function Invoke-InstallerPolicy([string[]]$policyArgs) {
+ $outFile = Join-Path $script:TempDir (
+ "policy-" + [Guid]::NewGuid().ToString("N") + ".txt")
+ $arguments = @("-viiperinstallerpolicy") + $policyArgs + @("--out", $outFile)
+
+ $quoted = @()
+ foreach ($argument in $arguments) {
+ if ($argument -match '\s') { $quoted += ('"' + $argument + '"') }
+ else { $quoted += $argument }
+ }
+
+ $process = Start-Process -FilePath $script:AppExecutable `
+ -ArgumentList $quoted -Wait -PassThru -WindowStyle Hidden
+ $exitCode = $process.ExitCode
+
+ if (-not (Test-Path -LiteralPath $outFile)) {
+ throw (
+ "The verification helper produced no result for " +
+ "'$($policyArgs -join ' ')' (exit code $exitCode). Setup cannot " +
+ "continue without one.")
+ }
+
+ $data = @{}
+ $reportedExit = $null
+ foreach ($line in [IO.File]::ReadAllLines($outFile,
+ [Text.Encoding]::UTF8)) {
+ if ([string]::IsNullOrEmpty($line)) { continue }
+ $separator = $line.IndexOf('=')
+ if ($separator -lt 1) { continue }
+ $key = $line.Substring(0, $separator)
+ $value = $line.Substring($separator + 1)
+ if ($key -eq "log") { Write-SetupLog $value }
+ elseif ($key -eq "exitcode") { $reportedExit = $value }
+ else { $data[$key] = $value }
+ }
+
+ if ($reportedExit -ne ([string]$exitCode)) {
+ throw (
+ "The verification helper's result does not match its exit code " +
+ "(reported '$reportedExit', process $exitCode). Setup treats that " +
+ "as unverified and stops.")
+ }
+
+ return @{ ExitCode = $exitCode; Data = $data }
}
function Invoke-Download([string]$url, [string]$outFile) {
@@ -116,88 +252,55 @@ function Invoke-Download([string]$url, [string]$outFile) {
throw "Download failed after three attempts: $($lastError.Message)"
}
-function Get-GithubReleaseAsset([string]$repo, [string]$assetPattern) {
- $apiUrl = "https://api.github.com/repos/$repo/releases?per_page=20"
- $releases = Invoke-RestMethod -Uri $apiUrl -TimeoutSec 30 -Headers @{
- "User-Agent" = "Thrum-VIIPER-Setup"
- "Accept" = "application/vnd.github+json"
- }
- if (-not $releases) { throw "No releases were found in $repo." }
-
- foreach ($release in @($releases | Where-Object { -not $_.draft })) {
- $asset = @($release.assets) |
- Where-Object { $_.name -match $assetPattern } |
- Sort-Object @{ Expression = {
- if ($_.name -match
- '(?i)^viiper-(windows|win)-(amd64|x64)\.zip$') { 0 }
- elseif ($_.name -match '(?i)^viiper\.exe$') { 1 }
- elseif ($_.name -match
- '(?i)(windows|win).*(amd64|x64).*\.(exe|zip)$') { 2 }
- elseif ($_.name -match '(?i)\.(exe|zip)$') { 3 }
- else { 4 }
- }}, name | Select-Object -First 1
- if ($asset) {
- $label = if ($release.tag_name) { $release.tag_name }
- elseif ($release.name) { $release.name } else { $release.id }
- Write-SetupLog (
- "Using '$($asset.name)' from $repo release '$label'.")
- return $asset.browser_download_url
- }
+<#
+ Fetches a pinned artefact and hands it to the verifier before anything is
+ done with it. There is no code path that returns an unverified file: a
+ refusal deletes the download and throws.
+
+ A caller-supplied local file takes the place of the download and nothing
+ else. It is copied in and verified against the same pin by the same call,
+ so staging a corrupted or wrongly-signed artefact exercises the refusal
+ rather than bypassing it — which is exactly what the VM run sheet's
+ negative cases need.
+#>
+function Get-VerifiedPinnedFile([string]$component, [hashtable]$pins,
+ [string]$destination, [string]$stagedFile) {
+ $url = $pins["$component.url"]
+ $expected = $pins["$component.sha256"]
+ if (-not $url -or -not $expected) {
+ throw "No pinned download is defined for '$component'."
}
- $names = @($releases | ForEach-Object { $_.assets } |
- ForEach-Object { $_.name }) -join ", "
- throw "No supported Windows VIIPER asset was found. Assets seen: $names"
-}
-
-function Get-ViiperAssetUrl {
- $errors = @()
- foreach ($repo in @("hbashton/VIIPER")) {
- try {
- Write-SetupLog "Checking release assets in $repo"
- return Get-GithubReleaseAsset $repo (
- "(?i)^(?!.*(libviiper|client|headers|linux|arm64|\.nupkg|" +
- "\.crate|\.tgz)).*\.(exe|zip)$")
- }
- catch {
- $errors += "${repo}: $($_.Exception.Message)"
- Write-SetupLog "Could not use ${repo}: $($_.Exception.Message)" Yellow
- }
- }
- throw "Could not locate VIIPER. $($errors -join '; ')"
-}
+ Write-SetupLog (
+ "Pinned $component release $($pins["$component.release"]): " +
+ "$($pins["$component.filename"]), expected SHA-256 $expected.")
-function Expand-ViiperAsset([string]$assetUrl, [string]$candidatePath) {
- $extension = [IO.Path]::GetExtension(([Uri]$assetUrl).AbsolutePath)
- $downloadPath = Join-Path $script:TempDir ("viiper-download" + $extension)
- Invoke-Download $assetUrl $downloadPath
-
- if ($extension -ieq ".exe") {
- Copy-Item -LiteralPath $downloadPath -Destination $candidatePath -Force
- }
- elseif ($extension -ieq ".zip") {
- $extractDir = Join-Path $script:TempDir "viiper-extract"
- Expand-Archive -LiteralPath $downloadPath -DestinationPath $extractDir `
- -Force
- $executable = Get-ChildItem -LiteralPath $extractDir -Recurse `
- -Filter "viiper.exe" | Select-Object -First 1
- if (-not $executable) {
- throw "The VIIPER archive did not contain viiper.exe."
+ if ($stagedFile) {
+ if (-not (Test-Path -LiteralPath $stagedFile)) {
+ throw "The staged $component file '$stagedFile' was not found."
}
- Copy-Item -LiteralPath $executable.FullName `
- -Destination $candidatePath -Force
+ Write-SetupLog (
+ "Using a staged local file instead of downloading. It is verified " +
+ "against the same pin.")
+ Copy-Item -LiteralPath $stagedFile -Destination $destination -Force
}
else {
- throw "Unsupported VIIPER asset type '$extension'."
+ Invoke-Download $url $destination
}
- $candidate = Get-Item -LiteralPath $candidatePath
- if ($candidate.Length -lt 65536) {
- throw "The downloaded VIIPER executable is unexpectedly small."
- }
- if ($candidate.Extension -ine ".exe") {
- throw "The downloaded VIIPER payload is not a Windows executable."
+ $verification = Invoke-InstallerPolicy @(
+ "verify-file", "--component", $component, "--path", $destination)
+ if ($verification.ExitCode -ne 0) {
+ try {
+ Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue
+ }
+ catch { }
+ throw (
+ "$($verification.Data['summary']) " +
+ "The downloaded file was discarded and nothing was run from it.")
}
+
+ Write-SetupLog $verification.Data['summary'] Green
}
function Install-ViiperAtomically([string]$candidatePath,
@@ -241,6 +344,17 @@ function Get-RunningViiperProcesses {
}
}
+<#
+ Stops every viiper.exe on the machine, retrying and escalating, and returns
+ false rather than pretending.
+
+ Worth knowing next to Thrum's runtime policy, which is the opposite: at
+ runtime the application refuses to stop a backend it did not start or that
+ is hosting a device. Here the rule is different on purpose — an install is
+ an explicit, elevated, user-initiated act, and a running image cannot be
+ replaced on Windows while it is held. The two policies are not in conflict;
+ they answer different questions.
+#>
function Stop-ViiperProcesses([string]$operation) {
$attempts = 12
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
@@ -316,54 +430,40 @@ function Test-ViiperApi([int]$timeoutMilliseconds = 1000) {
finally { if ($client) { $client.Dispose() } }
}
-function Start-AndVerifyViiper([string]$viiperPath) {
+<#
+ Starts the backend for the verification step with the update notifier
+ disabled.
+
+ The argument vector is not written here: it comes from the same constant the
+ application spawns with. VIIPER's bundled updater still points at the parent
+ project's releases and its "Update Now" pipes a remote script into an
+ elevated shell, so every path that starts a backend has to disable it —
+ including this one, which is not an autostart entry and was missed by the
+ runtime fix (issue #8).
+#>
+function Start-AndVerifyViiper([string]$viiperPath, [hashtable]$pins) {
if (Test-ViiperApi) { return $true }
- Start-Process -FilePath $viiperPath -ArgumentList "server" `
- -WindowStyle Hidden | Out-Null
- for ($attempt = 0; $attempt -lt 10; $attempt++) {
- Start-Sleep -Milliseconds 500
- if (Test-ViiperApi) { return $true }
- }
- return $false
-}
-function Register-ViiperRunTask([string]$viiperPath, [string]$taskName) {
- try {
- $taskAction = New-ScheduledTaskAction -Execute $viiperPath `
- -Argument "server"
- $taskTrigger = New-ScheduledTaskTrigger -AtLogOn
- $taskPrincipal = New-ScheduledTaskPrincipal `
- -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) `
- -RunLevel Highest -LogonType Interactive
- $taskSettings = New-ScheduledTaskSettingsSet `
- -AllowStartIfOnBatteries `
- -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) `
- -MultipleInstances IgnoreNew
-
- Register-ScheduledTask -TaskName $taskName -Action $taskAction `
- -Trigger $taskTrigger -Principal $taskPrincipal -Settings $taskSettings `
- -Force | Out-Null
- return $true
- }
- catch {
- Write-SetupLog "Failed modern scheduled task registration: $($_.Exception.Message)" Yellow
+ $serverArgs = $pins['viiper.serverargs']
+ if (-not $serverArgs) {
+ throw "The backend start arguments were not reported by the policy helper."
}
- try {
- $runCommand = '"{0}" server' -f $viiperPath
- $scheduledResult = Start-Process -FilePath "schtasks.exe" `
- -ArgumentList "/Create /F /TN `"$taskName`" /SC ONLOGON /RL HIGHEST /IT /TR `"$runCommand`"" `
- -WindowStyle Hidden -PassThru -Wait
- if ($scheduledResult.ExitCode -eq 0) {
- return $true
- }
-
- Write-SetupLog "Fallback schtasks command exited with code $($scheduledResult.ExitCode)." Yellow
- }
- catch {
- Write-SetupLog "Failed fallback scheduled task registration: $($_.Exception.Message)" Yellow
+ Write-SetupLog "Starting the backend for verification: viiper.exe $serverArgs"
+ $environmentName = $pins['viiper.updatenotifyenv']
+ $environmentValue = $pins['viiper.updatenotifyvalue']
+ if ($environmentName) {
+ # Belt and braces, exactly as the application does it: the flag is what
+ # takes effect, the variable is what a re-exec would inherit.
+ Set-Item -Path ("Env:" + $environmentName) -Value $environmentValue
}
+ Start-Process -FilePath $viiperPath -ArgumentList $serverArgs `
+ -WindowStyle Hidden | Out-Null
+ for ($attempt = 0; $attempt -lt 10; $attempt++) {
+ Start-Sleep -Milliseconds 500
+ if (Test-ViiperApi) { return $true }
+ }
return $false
}
@@ -378,77 +478,109 @@ try {
Write-SetupLog "Thrum VIIPER virtual controller setup" Green
Write-SetupLog "Installing or repairing VIIPER and usbip-win2."
+ $script:AppExecutable = Resolve-AppExecutable $AppExecutable
+ Write-SetupLog "Verification helper: $script:AppExecutable"
+
+ Write-Step "Pinned packages"
+ $pins = (Invoke-InstallerPolicy @("pins")).Data
+
Write-Step "Checking usbip-win2"
- $requiredUsbipVersion = [Version]"0.9.7.7"
- try {
- $usbipVersion = Get-UsbipInstalledVersion
- }
- catch {
- Write-SetupLog "usbip-win2 version check failed: $($_.Exception.Message)" Yellow
- $usbipVersion = $null
+ $registered = Get-UsbipRegisteredRelease
+ $usbipDecision = Invoke-InstallerPolicy @(
+ "usbip-decision", "--uninstall-version", $registered)
+ $action = $usbipDecision.Data['action']
+
+ switch ($action) {
+ "InstallPinned" {
+ $installerPath = Join-Path $script:TempDir $pins['usbip.filename']
+ Get-VerifiedPinnedFile "usbip" $pins $installerPath $UsbipInstallerFile
+
+ Write-SetupLog "Windows may briefly restart USB hub devices." Yellow
+ $installer = Start-Process -FilePath $installerPath `
+ -ArgumentList "/S" -PassThru -Wait
+ if ($installer.ExitCode -notin @(0, 1641, 3010)) {
+ throw "usbip-win2 setup failed with exit code $($installer.ExitCode)."
+ }
+ if ($installer.ExitCode -in @(1641, 3010)) {
+ $script:RebootRecommended = $true
+ Write-SetupLog "The installer asked for a Windows restart." Yellow
+ }
+ }
+ "AlreadyPinned" {
+ Write-SetupLog $usbipDecision.Data['summary'] Green
+ }
+ "LeaveRecognisedReleaseAlone" {
+ Write-SetupLog $usbipDecision.Data['summary'] Yellow
+ }
+ "RefuseUnrecognisedInstall" {
+ $script:Refused = $true
+ Write-SetupLog $usbipDecision.Data['summary'] Red
+ }
+ default {
+ # An action nobody wrote a branch for is not a licence to guess.
+ throw (
+ "The installer policy returned an unrecognised usbip-win2 " +
+ "action '$action'. Setup stops rather than guessing what it " +
+ "means.")
+ }
}
- if ($usbipVersion -and $usbipVersion -ge $requiredUsbipVersion) {
- Write-SetupLog "usbip-win2 is ready: $usbipVersion" Green
+
+ Write-Step "Validating the installed driver packages"
+ $validation = Invoke-InstallerPolicy @("validate-installed")
+ if ($validation.ExitCode -eq 0) {
+ $script:DriverValidated = $true
+ Write-SetupLog $validation.Data['summary'] Green
}
else {
- $state = if ($usbipVersion) { "old ($usbipVersion)" } else { "missing" }
- Write-SetupLog "usbip-win2 is $state; installing $requiredUsbipVersion." Yellow
- $usbipUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.7/USBip-0.9.7.7-x64.exe"
- $usbipInstaller = Join-Path $script:TempDir "USBip-0.9.7.7-x64.exe"
- Invoke-Download $usbipUrl $usbipInstaller
- Write-SetupLog "Windows may briefly restart USB hub devices." Yellow
- $installer = Start-Process -FilePath $usbipInstaller `
- -ArgumentList "/S" -PassThru -Wait
- if ($installer.ExitCode -notin @(0, 1641, 3010)) {
- throw "usbip-win2 setup failed with exit code $($installer.ExitCode)."
- }
- if ($installer.ExitCode -in @(1641, 3010)) {
- $script:RebootRecommended = $true
- }
- $usbipVersion = Get-UsbipInstalledVersion
- if (-not $usbipVersion) {
+ Write-SetupLog $validation.Data['summary'] Yellow
+ if (-not $script:Refused -and $action -eq "InstallPinned") {
+ # A pair that is not bound yet is the ordinary outcome of installing
+ # a kernel driver, not evidence of a bad one.
$script:RebootRecommended = $true
- Write-SetupLog "The driver will finish registering after a Windows restart." Yellow
+ Write-SetupLog (
+ "Restart Windows, then run Install / Repair again so the " +
+ "installed packages can be validated.") Yellow
}
}
Write-Step "Installing VIIPER"
$viiperPath = Join-Path $script:InstallDir "viiper.exe"
$candidatePath = Join-Path $script:TempDir "viiper.exe"
- Expand-ViiperAsset (Get-ViiperAssetUrl) $candidatePath
+ Get-VerifiedPinnedFile "viiper" $pins $candidatePath $ViiperBackendFile
Install-ViiperAtomically $candidatePath $viiperPath
Write-SetupLog "VIIPER installed to $viiperPath" Green
- Write-Step "Registering VIIPER"
- $registrationSafeToRun = $true
- if (-not (Stop-ViiperProcesses "install registration")) {
- $registrationSafeToRun = $false
- }
-
- $registration = Start-Process -FilePath $viiperPath `
- -ArgumentList "install" -WindowStyle Hidden -PassThru -Wait
- if ($registration.ExitCode -ne 0) {
- if (-not $registrationSafeToRun) {
- throw "VIIPER registration could not proceed because a VIIPER process could not be closed automatically. " +
- "Please close viiper.exe manually, then run Install / Repair again."
- }
- throw "VIIPER registration failed with exit code $($registration.ExitCode)."
- }
-
- $taskName = "RunVIIPER"
- if (Register-ViiperRunTask $viiperPath $taskName) {
- Write-SetupLog "Registered hidden logon task '$taskName'." Green
+ Write-Step "Startup behaviour"
+ # No autostart entry is created here, by either mechanism. Thrum starts the
+ # backend when a profile needs it and stops it again on exit, so a logon
+ # entry would start a backend the application never owns, never stops, and
+ # whose self-updater is enabled.
+ $autostartArgs = @("autostart")
+ if ($RemoveViiperAutostart) { $autostartArgs += "--remove" }
+ $autostart = Invoke-InstallerPolicy $autostartArgs
+ if ($autostart.ExitCode -ne 0) {
+ Write-SetupLog $autostart.Data['summary'] Yellow
+ }
+ elseif ($autostart.Data['action'] -eq "OfferRemoval") {
+ Write-SetupLog $autostart.Data['summary'] Yellow
+ Write-SetupLog (
+ "To remove it now, rerun setup with -RemoveViiperAutostart, or use " +
+ "Settings -> VIIPER in Thrum.") Yellow
}
else {
- Write-SetupLog "Could not create hidden logon task. Setup will continue; VIIPER can still be started by Thrum when needed." Yellow
+ Write-SetupLog $autostart.Data['summary']
}
Write-Step "Verification"
- if (Start-AndVerifyViiper $viiperPath) {
+ if (Start-AndVerifyViiper $viiperPath $pins) {
Write-SetupLog "VIIPER API is ready." Green
+ # The .previous backup is kept deliberately. Rollback that only exists
+ # inside the install window is not rollback: the failure this protects
+ # against is a backend that installs cleanly and then misbehaves.
$backupPath = "$viiperPath.previous"
if (Test-Path -LiteralPath $backupPath) {
- Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
+ Write-SetupLog (
+ "The previous backend was kept at $backupPath for rollback.")
}
}
elseif ($script:RebootRecommended) {
@@ -458,13 +590,29 @@ try {
throw "VIIPER installed, but its local API did not start. See $script:LogPath"
}
- Write-Host ""
- $finish = if ($script:RebootRecommended) {
- "Setup complete. Restart Windows once before using a virtual controller."
- } else {
- "Setup complete. VIIPER is ready for Thrum."
+ if ($script:Refused) {
+ $script:ExitCode = 1
+ Write-SetupLog (
+ "Setup finished, but the usbip-win2 packages on this machine are " +
+ "not ones this build recognises. Virtual controllers stay blocked " +
+ "until that is resolved; nothing was installed over them.") Red
+ }
+ elseif ($script:DriverValidated) {
+ $script:ExitCode = 0
+ Write-SetupLog "Setup complete. VIIPER is ready for Thrum." Green
+ }
+ elseif ($script:RebootRecommended) {
+ $script:ExitCode = 3
+ Write-SetupLog (
+ "Setup complete. Restart Windows once, then run Install / Repair " +
+ "again so the driver packages can be validated.") Yellow
+ }
+ else {
+ $script:ExitCode = 1
+ Write-SetupLog (
+ "Setup finished, but the installed driver packages could not be " +
+ "validated. Virtual controllers stay blocked until they are.") Red
}
- Write-SetupLog $finish Green
}
catch {
$script:ExitCode = 1