diff --git a/.gitignore b/.gitignore index 9010480..8deaa88 100644 --- a/.gitignore +++ b/.gitignore @@ -366,7 +366,9 @@ FodyWeavers.xsd ############################################################################### # WDK / driver build outputs [Dd]river/**/[Xx]64/ +[Dd]river/package/ *.cer +*.cat *.pvk # Local run-time config that shouldn't be versioned *.local.json diff --git a/driver/README.md b/driver/README.md index 89d42a1..f9f3c7e 100644 --- a/driver/README.md +++ b/driver/README.md @@ -47,9 +47,37 @@ producing `RioGamepad\x64\Release\RioGamepad.sys`. The project is a WDK/MSBuild > `.cat` is produced separately with `inf2cat.exe` and signed with `signtool.exe` > as part of install (below), rather than during compilation. -## Signing +## Test-signing & install (cabinets you own) -For the cockpit cabinets, enable **test signing** (`bcdedit /set testsigning on`) -and install a self-signed test certificate — appropriate for hardware you own. -Redistribution would instead use Microsoft **attestation signing** via Partner -Center. See the deployment notes in [../docs/PLAN.md](../docs/PLAN.md) (Phase 6). +Scripts in this folder automate the test-signing flow. Steps marked **(admin)** +need an elevated shell; everything else is non-admin. + +```cmd +:: 1. Build the driver (non-admin), EWDK mounted at E: +RioGamepad\build.cmd E: + +:: 2. Test-sign: makes a self-signed cert, builds + signs the .cat and .sys, +:: and exports RIOJoyTest.cer (non-admin) +powershell -ExecutionPolicy Bypass -File sign.ps1 -Ewdk E: + +:: 3. (admin) Trust the cert, stage the driver, enable test signing +powershell -ExecutionPolicy Bypass -File install.ps1 -Ewdk E: + +:: 4. REBOOT (test signing only takes effect after a restart) + +:: 5. (admin) Create the device; PnP then installs the staged driver +powershell -ExecutionPolicy Bypass -File install.ps1 -Ewdk E: -CreateDevice +``` + +Then open **`joy.cpl`** and confirm **"RIOJoy Virtual Gamepad"** appears with 6 +axes / POV / 96 buttons. To roll back: `uninstall.ps1 [-DisableTestSigning] [-RemoveCert]`. + +The signed package (`package/`) and the exported cert are local artifacts and are +git-ignored. Redistribution beyond owned hardware would instead use Microsoft +**attestation signing** via Partner Center (see [../docs/PLAN.md](../docs/PLAN.md), +Phase 6). + +The user-mode side opens this device by `GUID_DEVINTERFACE_RIOGAMEPAD` and feeds +reports via `IOCTL_RIO_SUBMIT_REPORT` — implemented by +`RioJoy.Core.Output.HidFeederJoystickSink`, which the tray app uses automatically +when the driver is present (falling back to a no-op sink when it is not). diff --git a/driver/install.ps1 b/driver/install.ps1 new file mode 100644 index 0000000..2bdf337 --- /dev/null +++ b/driver/install.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS + Trust the test cert, enable test signing, stage and install the RioGamepad + driver. REQUIRES AN ELEVATED (admin) shell. + +.DESCRIPTION + Two phases, because enabling test signing needs a reboot: + + install.ps1 (phase 1, pre-reboot) + - import RIOJoyTest.cer into LocalMachine Root + TrustedPublisher + - stage the signed package into the driver store (pnputil /add-driver) + - bcdedit /set testsigning on + - THEN REBOOT + + install.ps1 -CreateDevice (phase 2, after the reboot) + - create the root-enumerated device with devgen, which makes PnP install + the staged driver. Then check joy.cpl. + + Run driver\sign.ps1 first to produce the signed package + RIOJoyTest.cer. + +.PARAMETER Ewdk + Drive or root where the EWDK is mounted (default: E:). +.PARAMETER CreateDevice + Run phase 2 (create the device); use after the reboot. +#> +param( + [string]$Ewdk = "E:", + [switch]$CreateDevice +) + +$ErrorActionPreference = "Stop" +$scriptDir = $PSScriptRoot +$pkg = Join-Path $scriptDir "package" +$inf = Join-Path $pkg "RioGamepad.inf" +$cer = Join-Path $scriptDir "RIOJoyTest.cer" +$devgen = Join-Path $Ewdk "Program Files\Windows Kits\10\Tools\10.0.28000.0\x64\devgen.exe" +$hardwareId = "root\RioGamepad" + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "This script must be run from an elevated (Administrator) shell." +} + +if ($CreateDevice) { + if (-not (Test-Path $devgen)) { throw "devgen not found: $devgen" } + Write-Host "Creating device node ($hardwareId)..." + & $devgen /add /instanceid "RIOJOY01" /hardwareid $hardwareId + Write-Host "" + Write-Host "Done. Open 'joy.cpl' and look for 'RIOJoy Virtual Gamepad'." -ForegroundColor Green + Write-Host "If it shows a problem, check Device Manager and 'pnputil /enum-drivers'." + return +} + +if (-not (Test-Path $inf)) { throw "Signed package not found ($inf). Run driver\sign.ps1 first." } +if (-not (Test-Path $cer)) { throw "Certificate not found ($cer). Run driver\sign.ps1 first." } + +Write-Host "Trusting test certificate (LocalMachine Root + TrustedPublisher)..." +Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\Root | Out-Null +Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null + +Write-Host "Staging driver into the driver store..." +pnputil /add-driver $inf + +Write-Host "Enabling test signing..." +bcdedit /set testsigning on | Out-Null + +Write-Host "" +Write-Host "Phase 1 complete. REBOOT, then run: install.ps1 -CreateDevice" -ForegroundColor Yellow diff --git a/driver/sign.ps1 b/driver/sign.ps1 new file mode 100644 index 0000000..ac48be4 --- /dev/null +++ b/driver/sign.ps1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + Test-sign the RioGamepad driver package. No admin required. + +.DESCRIPTION + Creates (once) a self-signed code-signing certificate in the current user's + store, stages the built RioGamepad.sys + RioGamepad.inf into driver\package\, + generates the security catalog with Inf2Cat, and signs both the .sys and the + .cat with SHA-256. Exports the public cert (RIOJoyTest.cer) for the install + step to trust. + + Order matters: the .sys is embed-signed BEFORE the catalog is generated, so the + catalog hashes the signed binary; then the catalog itself is signed. + +.PARAMETER Ewdk + Drive or root where the EWDK is mounted (default: E:). +#> +param([string]$Ewdk = "E:") + +$ErrorActionPreference = "Stop" +$scriptDir = $PSScriptRoot +$projectDir = Join-Path $scriptDir "RioGamepad" +$buildOut = Join-Path $projectDir "x64\Release" +$pkg = Join-Path $scriptDir "package" + +$kitsBin = Join-Path $Ewdk "Program Files\Windows Kits\10\bin\10.0.28000.0" +$signtool = Join-Path $kitsBin "x64\signtool.exe" +$inf2cat = Join-Path $kitsBin "x86\Inf2Cat.exe" +$certName = "RIOJoy Test Cert" +$cerPath = Join-Path $scriptDir "RIOJoyTest.cer" + +foreach ($tool in @($signtool, $inf2cat)) { + if (-not (Test-Path $tool)) { throw "Tool not found: $tool (is the EWDK mounted at $Ewdk?)" } +} +if (-not (Test-Path (Join-Path $buildOut "RioGamepad.sys"))) { + throw "RioGamepad.sys not found. Build first: driver\RioGamepad\build.cmd $Ewdk" +} + +# 1. Self-signed code-signing certificate (idempotent). +$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -eq "CN=$certName" } | Select-Object -First 1 +if (-not $cert) { + Write-Host "Creating self-signed code-signing certificate '$certName'..." + $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=$certName" ` + -CertStoreLocation Cert:\CurrentUser\My -KeyExportPolicy Exportable ` + -NotAfter (Get-Date).AddYears(5) +} +Export-Certificate -Cert $cert -FilePath $cerPath -Force | Out-Null +Write-Host "Certificate thumbprint: $($cert.Thumbprint)" +Write-Host "Public cert exported to: $cerPath" + +# 2. Stage the package. +if (Test-Path $pkg) { Remove-Item $pkg -Recurse -Force } +New-Item -ItemType Directory -Path $pkg | Out-Null +Copy-Item (Join-Path $buildOut "RioGamepad.sys") $pkg +Copy-Item (Join-Path $buildOut "RioGamepad.inf") $pkg + +# 3. Embed-sign the .sys. +& $signtool sign /fd SHA256 /s My /n $certName (Join-Path $pkg "RioGamepad.sys") +if ($LASTEXITCODE -ne 0) { throw "signtool failed on RioGamepad.sys ($LASTEXITCODE)" } + +# 4. Generate the catalog (Win10 x64). +& $inf2cat /driver:$pkg /os:10_X64 /verbose +if ($LASTEXITCODE -ne 0) { throw "inf2cat failed ($LASTEXITCODE)" } + +# 5. Sign the catalog. +& $signtool sign /fd SHA256 /s My /n $certName (Join-Path $pkg "RioGamepad.cat") +if ($LASTEXITCODE -ne 0) { throw "signtool failed on RioGamepad.cat ($LASTEXITCODE)" } + +Write-Host "" +Write-Host "Signed package ready in: $pkg" -ForegroundColor Green +Write-Host "Next: run driver\install.ps1 from an elevated shell to trust the cert," -ForegroundColor Yellow +Write-Host "enable test signing, and install the device." -ForegroundColor Yellow +Write-Host "" +Write-Host "(Note: 'signtool verify /pa' will fail until the cert is trusted by install.ps1 —" -ForegroundColor DarkGray +Write-Host " that is expected for a self-signed test driver.)" -ForegroundColor DarkGray diff --git a/driver/uninstall.ps1 b/driver/uninstall.ps1 new file mode 100644 index 0000000..227ee13 --- /dev/null +++ b/driver/uninstall.ps1 @@ -0,0 +1,61 @@ +<# +.SYNOPSIS + Remove the RioGamepad device and driver. REQUIRES AN ELEVATED shell. + +.DESCRIPTION + Removes the device node, deletes the driver package from the store, and + (optionally) turns test signing back off and removes the trusted test cert. + +.PARAMETER DisableTestSigning + Also run 'bcdedit /set testsigning off' (needs a reboot to take effect). +.PARAMETER RemoveCert + Also remove the RIOJoy test certificate from the machine trust stores. +#> +param( + [switch]$DisableTestSigning, + [switch]$RemoveCert +) + +$ErrorActionPreference = "Continue" + +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "This script must be run from an elevated (Administrator) shell." +} + +# 1. Remove the device instance(s) matching our hardware id. +Write-Host "Removing RioGamepad device node(s)..." +$instances = (pnputil /enum-devices /connected /class System) 2>$null +& pnputil /remove-device /deviceid "root\RioGamepad" 2>$null + +# 2. Delete the driver package(s) from the store (oem*.inf published from RioGamepad.inf). +Write-Host "Locating staged driver package..." +$drivers = pnputil /enum-drivers +$published = $null +for ($i = 0; $i -lt $drivers.Count; $i++) { + if ($drivers[$i] -match 'Original Name:\s*RioGamepad\.inf') { + for ($j = $i; $j -ge 0; $j--) { + if ($drivers[$j] -match 'Published Name:\s*(oem\d+\.inf)') { $published = $Matches[1]; break } + } + } +} +if ($published) { + Write-Host "Deleting $published ..." + pnputil /delete-driver $published /uninstall /force +} else { + Write-Host "No staged RioGamepad driver package found." +} + +if ($RemoveCert) { + Write-Host "Removing test certificate from trust stores..." + Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\TrustedPublisher | + Where-Object { $_.Subject -eq "CN=RIOJoy Test Cert" } | + ForEach-Object { Remove-Item $_.PSPath -Force } +} + +if ($DisableTestSigning) { + Write-Host "Disabling test signing (reboot required)..." + bcdedit /set testsigning off | Out-Null +} + +Write-Host "Done." diff --git a/src/RioJoy.Core/Output/HidFeederJoystickSink.cs b/src/RioJoy.Core/Output/HidFeederJoystickSink.cs new file mode 100644 index 0000000..f515f6b --- /dev/null +++ b/src/RioJoy.Core/Output/HidFeederJoystickSink.cs @@ -0,0 +1,191 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; +using RioJoy.Core.Calibration; +using RioJoy.Core.Hid; +using RioJoy.Core.Mapping; + +namespace RioJoy.Core.Output; + +/// +/// that feeds the RioGamepad virtual HID driver: it +/// opens the driver's device interface, maintains the current +/// , and submits the whole report on every change via +/// DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT). Replaces +/// once the driver is installed (Phase 1/3b). +/// +[SupportedOSPlatform("windows")] +public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable +{ + // Must match driver/RioGamepad/Public.h. + private static readonly Guid InterfaceGuid = + new(0xb6a3f1c2, 0x7e84, 0x4d2a, 0x9c, 0x1f, 0x2a, 0x5e, 0x8d, 0x3b, 0x60, 0x71); + + // CTL_CODE(FILE_DEVICE_UNKNOWN=0x22, 0x800, METHOD_BUFFERED=0, FILE_WRITE_ACCESS=0x2). + private const uint IoctlSubmitReport = (0x22u << 16) | (0x2u << 14) | (0x800u << 2); + + private readonly SafeFileHandle _device; + private readonly RioHidReport _report = new(); + private readonly object _gate = new(); + + private HidFeederJoystickSink(SafeFileHandle device) => _device = device; + + /// + /// Try to open the virtual joystick driver. Returns if + /// the driver is not installed/present, so callers can fall back to + /// . + /// + public static bool TryCreate(out HidFeederJoystickSink? sink) + { + sink = null; + string? path = FindDevicePath(); + if (path is null) + return false; + + SafeFileHandle handle = CreateFileW( + path, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (handle.IsInvalid) + { + handle.Dispose(); + return false; + } + + sink = new HidFeederJoystickSink(handle); + sink.Submit(); // push the centered rest state + return true; + } + + public void SetButton(int button, bool pressed) + { + lock (_gate) + { + _report.SetButton(button, pressed); + Submit(); + } + } + + public void SetHat(RioHat position) + { + lock (_gate) + { + _report.SetHat(position); + Submit(); + } + } + + public void SetAxis(JoyAxis axis, int value) + { + lock (_gate) + { + _report.SetAxis(axis, value); + Submit(); + } + } + + private void Submit() + { + byte[] buffer = _report.ToArray(); + if (!DeviceIoControl(_device, IoctlSubmitReport, buffer, (uint)buffer.Length, + IntPtr.Zero, 0, out _, IntPtr.Zero)) + { + // Device may have been removed; surface for the caller's error handling. + throw new InvalidOperationException( + $"IOCTL_RIO_SUBMIT_REPORT failed (Win32 {Marshal.GetLastWin32Error()})."); + } + } + + public void Dispose() => _device.Dispose(); + + // --- SetupAPI device-interface resolution -------------------------------- + + private static string? FindDevicePath() + { + Guid guid = InterfaceGuid; + IntPtr devInfo = SetupDiGetClassDevs(ref guid, IntPtr.Zero, IntPtr.Zero, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + if (devInfo == INVALID_HANDLE_VALUE) + return null; + + try + { + var ifData = new SP_DEVICE_INTERFACE_DATA(); + ifData.cbSize = Marshal.SizeOf(); + + if (!SetupDiEnumDeviceInterfaces(devInfo, IntPtr.Zero, ref guid, 0, ref ifData)) + return null; + + uint required = 0; + SetupDiGetDeviceInterfaceDetail(devInfo, ref ifData, IntPtr.Zero, 0, ref required, IntPtr.Zero); + if (required == 0) + return null; + + IntPtr detail = Marshal.AllocHGlobal((int)required); + try + { + // SP_DEVICE_INTERFACE_DETAIL_DATA.cbSize: 8 on x64, 6 on x86. + Marshal.WriteInt32(detail, IntPtr.Size == 8 ? 8 : 6); + if (!SetupDiGetDeviceInterfaceDetail(devInfo, ref ifData, detail, required, ref required, IntPtr.Zero)) + return null; + + // DevicePath follows the cbSize DWORD. + return Marshal.PtrToStringUni(IntPtr.Add(detail, 4)); + } + finally + { + Marshal.FreeHGlobal(detail); + } + } + finally + { + SetupDiDestroyDeviceInfoList(devInfo); + } + } + + private const int DIGCF_PRESENT = 0x02; + private const int DIGCF_DEVICEINTERFACE = 0x10; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x01; + private const uint FILE_SHARE_WRITE = 0x02; + private const uint OPEN_EXISTING = 3; + private static readonly IntPtr INVALID_HANDLE_VALUE = new(-1); + + [StructLayout(LayoutKind.Sequential)] + private struct SP_DEVICE_INTERFACE_DATA + { + public int cbSize; + public Guid InterfaceClassGuid; + public int Flags; + public IntPtr Reserved; + } + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern IntPtr SetupDiGetClassDevs(ref Guid ClassGuid, IntPtr Enumerator, IntPtr hwndParent, int Flags); + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern bool SetupDiEnumDeviceInterfaces(IntPtr DeviceInfoSet, IntPtr DeviceInfoData, + ref Guid InterfaceClassGuid, uint MemberIndex, ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData); + + [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr DeviceInfoSet, + ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData, IntPtr DeviceInterfaceDetailData, + uint DeviceInterfaceDetailDataSize, ref uint RequiredSize, IntPtr DeviceInfoData); + + [DllImport("setupapi.dll", SetLastError = true)] + private static extern bool SetupDiDestroyDeviceInfoList(IntPtr DeviceInfoSet); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, + IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, + byte[] lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, + out uint lpBytesReturned, IntPtr lpOverlapped); +} diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs index c9a57be..bc3c8fe 100644 --- a/src/RioJoy.Tray/RioCoordinator.cs +++ b/src/RioJoy.Tray/RioCoordinator.cs @@ -28,6 +28,7 @@ public sealed class RioCoordinator : IDisposable private RioSerialLink? _link; private RioRuntime? _runtime; private CancellationTokenSource? _cts; + private IDisposable? _joystick; private string? _activeProfileName; public RioCoordinator(Func config, Func? transportFactory = null) @@ -104,20 +105,37 @@ public sealed class RioCoordinator : IDisposable try { + // Use the real virtual-joystick driver if installed; otherwise fall back + // to a no-op sink (keyboard/mouse still work) and note it in the status. + IJoystickSink joystick; + string joystickNote; + if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder)) + { + joystick = feeder!; + _joystick = feeder; + joystickNote = string.Empty; + } + else + { + joystick = new NullJoystickSink(); + _joystick = null; + joystickNote = " [no joystick driver]"; + } + _transport = _transportFactory(port); _link = new RioSerialLink(_transport); _runtime = new RioRuntime( _link, profile.ToInputMap(), new SendInputSink(), - new NullJoystickSink(), + joystick, new AxisCalibrator(profile.Calibration)); _cts = new CancellationTokenSource(); _ = _link.RunAsync(_cts.Token); _runtime.Start(); _activeProfileName = profile.Name; - SetStatus($"Active: {profile.Name} ({_link.Description})"); + SetStatus($"Active: {profile.Name} ({_link.Description}){joystickNote}"); } catch (Exception ex) { @@ -143,6 +161,9 @@ public sealed class RioCoordinator : IDisposable _link = null; + _joystick?.Dispose(); // closes the HID feeder handle + _joystick = null; + _transport?.Dispose(); // releases the COM port _transport = null;