diff --git a/.github/workflows/version-consistency.yml b/.github/workflows/version-consistency.yml index 7fd8465..0cfe860 100644 --- a/.github/workflows/version-consistency.yml +++ b/.github/workflows/version-consistency.yml @@ -14,6 +14,11 @@ name: version-consistency # vcpkg does not read /VERSION, so it drifts silently # without this check (it sat at 1.0.0 through 1.1.0). # +# Bundled-driver pins: src/platform/windows/driver_pins.h is what the +# dashboard's driver banner compares installed drivers against, and +# installer.iss carries the same ViGEmBus / HIDMaestro versions for the +# payload it ships. They must agree. +# # A PR-time text-consistency gate, not a full re-build. on: @@ -21,6 +26,7 @@ on: paths: - "VERSION" - "src/core/version.h" + - "src/platform/windows/driver_pins.h" - "installer.iss" - "vcpkg.json" - ".github/workflows/version-consistency.yml" @@ -29,6 +35,7 @@ on: paths: - "VERSION" - "src/core/version.h" + - "src/platform/windows/driver_pins.h" - "installer.iss" - "vcpkg.json" @@ -98,9 +105,21 @@ jobs: echo "vcpkg.json version = $vcpkg_ver" [ "$vcpkg_ver" = "$ver" ] || { echo "::error::vcpkg.json version=$vcpkg_ver, expected $ver"; fail=1; } + # ── bundled driver pins ─────────────────────────────────────── + pins=src/platform/windows/driver_pins.h + pin_vigem=$(grep -E '^#define[[:space:]]+SATELLITE_VIGEMBUS_BUNDLED_VERSION' "$pins" | sed -E 's/.*"([^"]+)".*/\1/') + pin_hm=$(grep -E '^#define[[:space:]]+SATELLITE_HIDMAESTRO_SDK_VERSION' "$pins" | sed -E 's/.*"([^"]+)".*/\1/') + pin_hm_drv=$(grep -E '^#define[[:space:]]+SATELLITE_HIDMAESTRO_BUNDLED_DRIVER_VERSION' "$pins" | sed -E 's/.*"([^"]+)".*/\1/') + iss_vigem=$(grep -E '^#define[[:space:]]+ViGEmBusVersion' installer.iss | sed -E 's/.*"([^"]+)".*/\1/') + iss_hm=$(grep -E '^#define[[:space:]]+HmVersion' installer.iss | sed -E 's/.*"([^"]+)".*/\1/') + echo "driver_pins.h vigem=$pin_vigem hidmaestro-sdk=$pin_hm hidmaestro-driver=$pin_hm_drv; installer.iss vigem=$iss_vigem hidmaestro=$iss_hm" + [ -n "$pin_vigem" ] && [ "$pin_vigem" = "$iss_vigem" ] || { echo "::error::ViGEmBus pin drift: driver_pins.h=$pin_vigem installer.iss=$iss_vigem"; fail=1; } + [ -n "$pin_hm" ] && [ "$pin_hm" = "$iss_hm" ] || { echo "::error::HIDMaestro SDK pin drift: driver_pins.h=$pin_hm installer.iss=$iss_hm"; fail=1; } + [[ "$pin_hm_drv" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]] || { echo "::error::SATELLITE_HIDMAESTRO_BUNDLED_DRIVER_VERSION '$pin_hm_drv' is not a dotted version"; fail=1; } + if [ "$fail" -ne 0 ]; then echo - echo "::error::Version sources drifted. Bump /VERSION, src/core/version.h and vcpkg.json together; installer.iss derives automatically." + echo "::error::Version sources drifted. Bump /VERSION, src/core/version.h and vcpkg.json together; installer.iss derives automatically. Driver pins: keep src/platform/windows/driver_pins.h and the installer.iss #defines in step." exit 1 fi echo "All version sources agree on $ver." diff --git a/CHANGELOG.md b/CHANGELOG.md index 703577c..357293b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,59 @@ working. The HIDMaestro backend, the composite audio personas and the unified build story (one script contract shared by CI and local builds) land here too. +No elevation prompt when a controller connects. Creating a HIDMaestro +virtual device needs an administrator token, and until now Satellite got +one by spawning `satellite-hm-helper.exe` with `runas` on the first +HIDMaestro plug of each session: one UAC prompt, at the exact moment +nobody is at the PC, because the pad is on the phone across the room. Setup +now registers the same helper as the LocalSystem service `SatelliteHmBroker` +("Satellite Controller Broker"), demand-start, and Satellite talks to it over +the well-known pipe `\\.\pipe\satellite-hm-broker` instead. Nothing about +the hot path changes; the broker only does what the spawned helper did +(create the SwDevice, duplicate the section and event handles into the +satellite process, tear down on disconnect). What changes is who holds the +token: the pipe's DACL admits interactive logons only, the broker admits a +connection only when the client is the installed `satellite.exe` beside it, +and Satellite accepts the pipe only when its server PID is the registered +service's PID, so a squatted pipe name is refused rather than trusted. +Setup grants interactive users SERVICE_START and installs a named-pipe +service trigger, so the service is started by whoever connects first and +exits after five idle minutes; a PC with no controller plugged runs no +broker. The `runas` path stays as the fallback when the service is absent +(`/HIDMAESTRO=skip` installs, hand-removed service, a second concurrent +session refused as `busy`), so the one remaining UAC prompt is the one an +administrator opted into. The helper gains `service` (SCM-hosted) and +`broker` (console, for debugging) modes; `hello` answers `"broker":true` +over the service so the log says which path a session took. + +Driver status on the Windows dashboard, and an upgrade path that never reboots +behind your back. `GET /api/backend/status` and `GET /api/server/capabilities` +now fill each backend's `driverVersion` on Windows (the ViGEmBus.sys file +version; the HIDMaestro driver-store INF `DriverVer`) and gain three additive +fields beside it: `bundledVersion` (what this Satellite build's installer +ships for that driver, null where it ships nothing), `versionState` +(`current` / `outdated` / `newer` / `unknown`, derived server-side so no client +has to compare version strings) and `restartPending` (ViGEmBus only: the bus +device node reports `DN_NEED_RESTART`, which is what a driver upgrade that +returned 3010 leaves behind until Windows restarts). The dashboard renders a +driver banner off those fields on Windows hosts: a quiet green strip when both +drivers are installed and current, and an amber or red banner naming what is +missing, outdated, unresponsive or waiting on a restart, with the fix as the +action. If a Satellite update is already available or downloaded, the banner +routes to it, since the installer carries both drivers; otherwise it links the +installer for the running version. The upgrade path itself had one real hole: +the in-app updater ran the installer `/VERYSILENT` without `/NORESTART`, and a +bundled ViGEmBus upgrade over an older driver (1.21.x is common in the field) +can return 3010, which a very-silent Inno run answers by rebooting the PC +without asking. The updater now passes `/NORESTART`, the installer logs driver +failures instead of raising a modal when it runs under `/OTA` (there is nobody +at the keyboard to dismiss one), and the banner's restart-pending row is how +the user learns the reboot is owed. The pinned versions live in one header +(`src/platform/windows/driver_pins.h`) and `version-consistency.yml` fails the +build if `installer.iss` drifts from it; bumping the HIDMaestro SDK now also +means re-reading the INF version its `HIDMaestro.Core.dll` embeds (the recipe +is in `redist/README.md`). + Controller audio, split per direction and no longer paying for silence: one `controllerAudio` switch turned both directions on together, so a host that wanted the pad's microphone had to accept its speaker too, and the speaker diff --git a/CMakeLists.txt b/CMakeLists.txt index 10b4947..af6c90c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -242,6 +242,7 @@ if(WIN32) # CM_Get_DevNode_PropertyW, for walking an audio endpoint's PnP parent # chain: setupapi forwards CM_Locate_DevNodeW but not the property API. cfgmgr32 + version # WinRT: RoActivateInstance / RoGetActivationFactory + HSTRING for the # actionable pairing toast (src/platform/windows/toast.cpp). runtimeobject @@ -885,6 +886,7 @@ satellite_add_pure_test(test_origin_guard tests/test_origin_guard.cpp) satellite_add_pure_test(test_status_json tests/test_status_json.cpp) satellite_add_pure_test(test_backend_registry tests/test_backend_registry.cpp src/core/backend_registry.cpp) +satellite_add_pure_test(test_driver_inf tests/test_driver_inf.cpp) satellite_add_pure_test(test_gamepad_mux tests/test_gamepad_mux.cpp) # The audio reorder window (core/audio/audio_jitter.h) is header-only and diff --git a/README.md b/README.md index 7a54ee1..50c1747 100644 --- a/README.md +++ b/README.md @@ -87,19 +87,60 @@ Setup's standard `/SILENT` / `/VERYSILENT`: | *(none)* / `/VIGEM=auto` | Default. Install the bundled ViGEmBus only if missing or older than 1.22.0. | | `/VIGEM=bundled` | Force-run the bundled installer regardless of what's already there. | | `/VIGEM=skip` | Don't touch the driver. Use this on locked-down machines or when ViGEmBus is managed externally. | -| *(none)* / `/HIDMAESTRO=auto` | Default. Deploy/refresh the bundled HIDMaestro driver (idempotent, no reboot). | -| `/HIDMAESTRO=skip` | Don't touch the HIDMaestro driver. | - -A reboot is sometimes required on first ViGEmBus install (MSI exit code 3010). -The Satellite installer surfaces this as a "Restart now / later" prompt -on the final wizard page. Until you reboot, virtual-gamepad output may not -work even though the driver is installed. HIDMaestro never needs a reboot. - -At runtime, the first HIDMaestro controller you plug in each Satellite -session shows one Windows elevation prompt: creating the virtual device -needs administrator rights, which Satellite (running unelevated) delegates -to `satellite-hm-helper.exe` for that session. ViGEm-only sessions never -see a prompt. +| *(none)* / `/HIDMAESTRO=auto` | Default. Deploy/refresh the bundled HIDMaestro driver (idempotent, no reboot) and register the Satellite Controller Broker service. | +| `/HIDMAESTRO=skip` | Don't touch the HIDMaestro driver, and don't register the broker service (an existing one is removed). | + +A reboot is sometimes required on first ViGEmBus install or on an upgrade +over an older ViGEmBus (MSI exit code 3010). The Satellite installer surfaces +this as a "Restart now / later" prompt on the final wizard page. Until you +reboot, virtual-gamepad output may not work even though the driver is +installed. HIDMaestro never needs a reboot. + +### Upgrading + +Running a newer `SatelliteSetup.exe` over an existing install (or letting +the in-app updater do it) upgrades everything in place: + +- The app and web UI are replaced; your config, pairings and the autostart / + desktop-icon choices are kept. +- **ViGEmBus** is upgraded only if the installed `ViGEmBus.sys` is older than + the bundled 1.22.0 (compared by file version). Same or newer is left alone. + An upgrade that replaces a loaded kernel driver can require a restart. +- **HIDMaestro** is re-deployed from the bundled helper every time the + component is selected. The SDK compares the installed driver's manifest hash + to the embedded one, so a same-version run is a ~50 ms no-op and a bumped + pin is a real redeploy. No reboot. +- Component selection follows Inno Setup's memory of your previous install: a + "Full" install picks up new components (HIDMaestro was added in 2.0.0); a + "Custom" install keeps exactly the components you picked before, so the new + driver is not added silently. The dashboard tells you either way. +- The in-app updater runs the installer `/VERYSILENT /NORESTART`, so it never + reboots the PC by itself. Driver failures are written to the setup log under + `%TEMP%` instead of raising a dialog nobody is there to dismiss. + +The dashboard shows a **driver banner** on Windows: which of the two drivers is +installed, its version against the version this Satellite build bundles, and +whether ViGEmBus is waiting on a restart to finish an upgrade. It stays a small +green strip while everything is current and turns amber or red with the fix as +the action: install the pending Satellite update (which carries both drivers) +or re-run the installer for the running version. + +Creating a HIDMaestro virtual device needs administrator rights, and +Satellite itself runs unelevated. Setup therefore registers the bundled +`satellite-hm-helper.exe` as the **Satellite Controller Broker** service +(`SatelliteHmBroker`, LocalSystem, demand-start). When a HIDMaestro +controller connects, Satellite reaches the broker over a named pipe, the +broker creates the device and hands the shared-memory handles back, and no +elevation prompt appears, whether or not anyone is at the PC. The service +starts on the first connection (Windows' named-pipe service trigger, or +Satellite starting it directly, which setup permits) and exits after five +idle minutes, so it costs nothing while no controller is plugged. The broker +admits only interactive logons and only the installed `satellite.exe` +beside it; Satellite in turn accepts the pipe only when its server is the +registered service's process. If the service is missing (installed with +`/HIDMAESTRO=skip`, or removed by hand), Satellite falls back to spawning the +helper elevated for the session, which is the one case that still shows a +UAC prompt. ViGEm-only sessions never involve either path. ### Controller audio diff --git a/SECURITY.md b/SECURITY.md index bcaf78d..8f4e8ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,11 +59,21 @@ In scope: - The pairing flow (PIN paths A/B on `:9443`) + the loopback-only admin HTTP/SSE web UI exposed by `satellite`. +- On Windows, the `SatelliteHmBroker` service boundary: the LocalSystem + broker that creates HIDMaestro virtual devices for the unelevated + `satellite.exe` over `\\.\pipe\satellite-hm-broker` (interactive-logon + DACL, client image-path check; see `docs/architecture.md`, "Elevation + story"). A way for a non-interactive or remote caller to reach it, or to + obtain anything beyond virtual controller / audio device creation through + it, is in scope. + Out of scope: - Anything that requires the attacker to already have local privileges on the user's PC (root, Administrator, ability to drop binaries in - `%APPDATA%`, etc.). + `%APPDATA%`, etc.). A process already running as the signed-in user can + reach the broker by design; that is the same trust it already holds over + the user's own session. - The vendored ViGEmBus driver itself; file with [nefarius/ViGEmBus](https://github.com/nefarius/ViGEmBus). - The bundled HIDMaestro driver/SDK itself; file with [hifihedgehog/HIDMaestro](https://github.com/hifihedgehog/HIDMaestro). - DoS via raw network flooding. UDP without rate-limit is a known diff --git a/docs/architecture.md b/docs/architecture.md index 63c3f0d..9460133 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -508,13 +508,31 @@ ring (DS5 report 0x02, valid_flag1 bit 0 gating byte 8) as `setMicLedCallback`. Satellite stays asInvoker. Device lifecycle needs an elevated token (SwDevice creation, `Global\` section creation, driver deploy), so it lives in the bundled .NET helper (`helper/hidmaestro` → -`satellite-hm-helper.exe`), spawned once per session on the FIRST -HIDMaestro plug (one UAC prompt; never from a status probe). The helper -drives the HIDMaestro SDK and duplicates the per-controller section + -event handles into the satellite process over a private named pipe -(`hidmaestro_helper_client.cpp`; the connecting client's PID is -verified against the spawned process). After that the per-frame hot -path is native only. If the helper dies, submits keep working (the +`satellite-hm-helper.exe`). The helper drives the HIDMaestro SDK and +duplicates the per-controller section + event handles into the +satellite process over a named pipe (`hidmaestro_helper_client.cpp`). +Two transports reach it, tried in order on the FIRST HIDMaestro plug +(never from a status probe): + +1. The **SatelliteHmBroker** service (`satellite-hm-helper.exe + service`): LocalSystem, demand-start, registered by setup with the + hidmaestro component. It listens on `\\.\pipe\satellite-hm-broker` + with a DACL that admits interactive logons only, and admits a + connection only when the client PID is the installed `satellite.exe` + beside it in an interactive session. Satellite starts it if stopped + (setup grants SERVICE_START to interactive users, and the SCM's + named-pipe trigger covers the same case), then accepts the pipe only + if `GetNamedPipeServerProcessId` equals the service's PID from + `QueryServiceStatusEx`, so a squatted pipe name is refused. One + client session at a time (the SDK's orphan sweep is machine-global); + a second is answered `busy`. The service exits after five idle + minutes and the trigger brings it back. No UAC prompt. +2. A helper spawned `runas` with a private, per-session pipe name (one + UAC prompt; the connecting client's PID is verified against the + spawned process). This is the fallback when the service is absent or + busy. + +After that the per-frame hot path is native only. If the helper dies, submits keep working (the mapped sections outlive it); the next unplug reports unconfirmed and quarantines the serial, matching the ViGEm zombie-target contract. The installer's optional-but-default "hidmaestro" component deploys the diff --git a/docs/contract.md b/docs/contract.md index 3903da2..dfad9f5 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -294,7 +294,8 @@ caller's own session. "backends": [ { "id": "vigem", "vendor": "Nefarius Software Solutions", "displayName": "ViGEmBus", "kernelMode": true, "audio": false, "available": true, "errorCode": null, - "lifecycle": "eol", "eolDate": "2023-11-02", "driverVersion": null, + "lifecycle": "eol", "eolDate": "2023-11-02", "driverVersion": "1.22.0.0", + "bundledVersion": "1.22.0", "versionState": "current", "restartPending": false, "controllers": [ { "type": 0, "name": "xbox", "latency": "lowest", "latencyRank": 0, "motion": false, "touchpad": false, "lightbar": false, "motionRequires": null, @@ -309,7 +310,8 @@ caller's own session. ] }, { "id": "hidmaestro", "vendor": "hifihedgehog", "displayName": "HIDMaestro", "kernelMode": false, "audio": true, "available": true, "errorCode": null, - "lifecycle": "supported", "eolDate": null, "driverVersion": null, + "lifecycle": "supported", "eolDate": null, "driverVersion": "1.4.7.12", + "bundledVersion": "1.4.7.12", "versionState": "current", "restartPending": false, "controllers": [ { "type": 0, "name": "xbox", "latency": "medium", "latencyRank": 2, "motion": false, "touchpad": false, "lightbar": false, "motionRequires": null, @@ -382,6 +384,20 @@ copy a client renders for them is the client's own. A backend being `eol` says n about whether it works — `available` is the runtime truth, and an `eol` backend may well be the preferred one. +`bundledVersion`, `versionState` and `restartPending` (additive; absent on older +servers) are the install-state companions to `driverVersion`. `bundledVersion` is the +driver version the running server's own installer ships for that backend (null where it +ships none: uinput, machid, and any build without a pin). `versionState` ∈ `current` | +`outdated` | `newer` | `unknown` is `driverVersion` compared numerically against +`bundledVersion`, derived server-side so a client never parses version strings; it is +`unknown` whenever either side is null, including a missing driver (whose `errorCode` +already says so). `restartPending` is true when the driver was replaced on disk but the +loaded one is still the old one and Windows needs a restart to finish (ViGEmBus reports +this on its bus device node); it is false everywhere else. On Windows `driverVersion` is +the `ViGEmBus.sys` file version for `vigem` and the driver-store INF `DriverVer` for +`hidmaestro` (the HIDMaestro SDK release and its UMDF driver carry different version +schemes; the INF is what Device Manager shows and what a bumped pin changes). + `host` is the receiver's OWN capability inventory, readable before pairing or any catalog round-trip so a client reflects the real receiver instead of an optimistic default. Each entry's `supported` is the static fact (mirrors the catalog diff --git a/helper/hidmaestro/Program.cs b/helper/hidmaestro/Program.cs index a9caa15..a541e40 100644 --- a/helper/hidmaestro/Program.cs +++ b/helper/hidmaestro/Program.cs @@ -1,6 +1,9 @@ // SPDX-License-Identifier: LGPL-3.0-or-later using System.IO.MemoryMappedFiles; +using System.IO.Pipes; using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text; using System.Text.Json; using HIDMaestro; @@ -17,6 +20,8 @@ private static int Main(string[] args) { > 0 when args[0] == "serve" => Serve(ParseOption(args, "--pipe"), ParsePid(args)), + > 0 when args[0] == "service" => ServiceHost.Run(), + > 0 when args[0] == "broker" => RunBrokerConsole(), > 0 when args[0] == "install-driver" => InstallDriver(), > 0 when args[0] == "remove-driver" => RemoveDriver(), > 0 when args[0] == "cleanup" => Cleanup(), @@ -34,7 +39,7 @@ private static int Usage() { Console.Error.WriteLine( "usage: satellite-hm-helper --parent-pid " + - " | install-driver | remove-driver | cleanup>"); + " | service | broker | install-driver | remove-driver | cleanup>"); return 2; } @@ -99,28 +104,405 @@ private static int Serve(string pipePath, int parentPid) ".", pipeName, System.IO.Pipes.PipeDirection.InOut); pipe.Connect(30000); + Pump(pipe, session); + return 0; + } + + private static void Pump(Stream pipe, Session session) + { using var reader = new StreamReader(pipe, new UTF8Encoding(false)); using var writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true }; while (true) { - string? line = reader.ReadLine(); - if (line == null) break; // satellite closed the pipe + string? line; + try + { + line = reader.ReadLine(); + } + catch (IOException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + if (line == null) break; string response = session.Handle(line, out bool quit); - writer.Write(response); - writer.Write('\n'); + try + { + writer.Write(response); + writer.Write('\n'); + } + catch (IOException) + { + break; + } if (quit) break; } + } + + private static int RunBrokerConsole() + { + var broker = new Broker(idleExit: false); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + broker.Stop(); + }; + Console.Error.WriteLine($"satellite-hm-helper: broker listening on \\\\.\\pipe\\{Broker.PipeName}"); + broker.Run(); return 0; } + internal sealed class Broker + { + public const string PipeName = "satellite-hm-broker"; + private static readonly TimeSpan IdleExitAfter = TimeSpan.FromMinutes(5); + + private readonly bool _idleExit; + private readonly string _expectedClient; + private readonly CancellationTokenSource _stop = new(); + private readonly object _lock = new(); + private bool _busy; + private Timer? _idle; + + public Broker(bool idleExit) + { + _idleExit = idleExit; + string dir = Path.GetDirectoryName(Environment.ProcessPath ?? "") ?? AppContext.BaseDirectory; + _expectedClient = Path.GetFullPath(Path.Combine(dir, "satellite.exe")); + } + + public void Stop() + { + _stop.Cancel(); + } + + public void Run() + { + ArmIdleTimer(); + while (!_stop.IsCancellationRequested) + { + NamedPipeServerStream pipe; + try + { + pipe = CreateServerPipe(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"satellite-hm-helper: pipe create failed: {ex.Message}"); + if (_stop.Token.WaitHandle.WaitOne(1000)) break; + continue; + } + try + { + pipe.WaitForConnectionAsync(_stop.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + pipe.Dispose(); + break; + } + catch (Exception ex) + { + Console.Error.WriteLine($"satellite-hm-helper: accept failed: {ex.Message}"); + pipe.Dispose(); + continue; + } + var t = new Thread(() => ServeClient(pipe)) { IsBackground = true, Name = "hm-broker-client" }; + t.Start(); + } + } + + private NamedPipeServerStream CreateServerPipe() + { + var security = new PipeSecurity(); + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + PipeAccessRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + PipeAccessRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.InteractiveSid, null), + PipeAccessRights.ReadWrite, AccessControlType.Allow)); + using (var self = WindowsIdentity.GetCurrent()) + { + if (self.User != null) + security.AddAccessRule(new PipeAccessRule(self.User, PipeAccessRights.FullControl, + AccessControlType.Allow)); + } + return NamedPipeServerStreamAcl.Create( + PipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 64 * 1024, 64 * 1024, security); + } + + private void ServeClient(NamedPipeServerStream pipe) + { + using (pipe) + { + int pid = 0; + string? reason = Authorize(pipe, out pid); + if (reason != null) + { + WriteLine(pipe, Error(reason)); + return; + } + + lock (_lock) + { + if (_busy) + { + WriteLine(pipe, Error("busy")); + return; + } + _busy = true; + _idle?.Change(Timeout.Infinite, Timeout.Infinite); + } + + try + { + using var client = System.Diagnostics.Process.GetProcessById(pid); + using var session = new Session(pid, broker: true); + client.EnableRaisingEvents = true; + client.Exited += (_, _) => + { + try { pipe.Dispose(); } catch { /* unblocks the reader */ } + }; + if (client.HasExited) return; + Pump(pipe, session); + } + catch (Exception ex) + { + Console.Error.WriteLine($"satellite-hm-helper: session ended: {ex.Message}"); + } + finally + { + lock (_lock) + { + _busy = false; + } + ArmIdleTimer(); + } + } + } + + private string? Authorize(NamedPipeServerStream pipe, out int pid) + { + pid = 0; + if (!GetNamedPipeClientProcessId(pipe.SafePipeHandle.DangerousGetHandle(), out uint upid)) + return "client pid unavailable"; + pid = checked((int)upid); + if (!ProcessIdToSessionId(upid, out uint session) || session == 0) + return "client not interactive"; + string? path = ImagePath(upid); + if (path == null) return "client image unavailable"; + if (!string.Equals(Path.GetFullPath(path), _expectedClient, StringComparison.OrdinalIgnoreCase)) + return "client not satellite.exe"; + return null; + } + + private static string? ImagePath(uint pid) + { + nint h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, checked((int)pid)); + if (h == 0) return null; + try + { + var sb = new StringBuilder(32768); + int len = sb.Capacity; + return QueryFullProcessImageNameW(h, 0, sb, ref len) ? sb.ToString(0, len) : null; + } + finally + { + CloseHandle(h); + } + } + + private static void WriteLine(Stream pipe, string line) + { + try + { + byte[] bytes = Encoding.UTF8.GetBytes(line + "\n"); + pipe.Write(bytes, 0, bytes.Length); + pipe.Flush(); + } + catch { /* client gone */ } + } + + private void ArmIdleTimer() + { + if (!_idleExit) return; + lock (_lock) + { + _idle ??= new Timer(_ => + { + lock (_lock) + { + if (_busy) return; + } + Stop(); + }, null, Timeout.Infinite, Timeout.Infinite); + _idle.Change(IdleExitAfter, Timeout.InfiniteTimeSpan); + } + } + + private static string Error(string message) + { + using var stream = new MemoryStream(); + using (var w = new Utf8JsonWriter(stream)) + { + w.WriteStartObject(); + w.WriteBoolean("ok", false); + w.WriteString("error", message); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetNamedPipeClientProcessId(nint pipe, out uint clientPid); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ProcessIdToSessionId(uint pid, out uint sessionId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint OpenProcess(uint access, bool inherit, int pid); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool QueryFullProcessImageNameW(nint process, uint flags, StringBuilder name, + ref int size); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(nint handle); + } + + internal static class ServiceHost + { + public const string ServiceName = "SatelliteHmBroker"; + + private const int SERVICE_WIN32_OWN_PROCESS = 0x10; + private const int SERVICE_STOPPED = 1; + private const int SERVICE_START_PENDING = 2; + private const int SERVICE_STOP_PENDING = 3; + private const int SERVICE_RUNNING = 4; + private const int SERVICE_ACCEPT_STOP = 1; + private const int SERVICE_ACCEPT_SHUTDOWN = 4; + private const int SERVICE_CONTROL_STOP = 1; + private const int SERVICE_CONTROL_INTERROGATE = 4; + private const int SERVICE_CONTROL_SHUTDOWN = 5; + private const int ERROR_CALL_NOT_IMPLEMENTED = 120; + private const int ERROR_FAILED_SERVICE_CONTROLLER_CONNECT = 1063; + + [StructLayout(LayoutKind.Sequential)] + private struct SERVICE_STATUS + { + public int dwServiceType; + public int dwCurrentState; + public int dwControlsAccepted; + public int dwWin32ExitCode; + public int dwServiceSpecificExitCode; + public int dwCheckPoint; + public int dwWaitHint; + } + + private delegate void ServiceMainProc(int argc, nint argv); + private delegate int HandlerExProc(int control, int eventType, nint eventData, nint context); + + private static ServiceMainProc? s_main; + private static HandlerExProc? s_handler; + private static nint s_status; + private static Broker? s_broker; + + public static int Run() + { + s_main = ServiceMain; + nint name = Marshal.StringToHGlobalUni(ServiceName); + nint table = Marshal.AllocHGlobal(nint.Size * 4); + Marshal.WriteIntPtr(table, 0, name); + Marshal.WriteIntPtr(table, nint.Size, Marshal.GetFunctionPointerForDelegate(s_main)); + Marshal.WriteIntPtr(table, nint.Size * 2, 0); + Marshal.WriteIntPtr(table, nint.Size * 3, 0); + if (!StartServiceCtrlDispatcherW(table)) + { + int err = Marshal.GetLastWin32Error(); + Console.Error.WriteLine(err == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT + ? "satellite-hm-helper: 'service' must be started by the Service Control Manager; use 'broker' to run in a console." + : $"satellite-hm-helper: StartServiceCtrlDispatcher failed ({err})"); + return 1; + } + return 0; + } + + private static void ServiceMain(int argc, nint argv) + { + s_handler = Handler; + s_status = RegisterServiceCtrlHandlerExW(ServiceName, s_handler, 0); + if (s_status == 0) return; + Report(SERVICE_START_PENDING, 0, 3000); + s_broker = new Broker(idleExit: true); + Report(SERVICE_RUNNING, SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN, 0); + try + { + s_broker.Run(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"satellite-hm-helper: broker failed: {ex.Message}"); + } + Report(SERVICE_STOPPED, 0, 0); + } + + private static int Handler(int control, int eventType, nint eventData, nint context) + { + switch (control) + { + case SERVICE_CONTROL_STOP: + case SERVICE_CONTROL_SHUTDOWN: + Report(SERVICE_STOP_PENDING, 0, 5000); + s_broker?.Stop(); + return 0; + case SERVICE_CONTROL_INTERROGATE: + return 0; + default: + return ERROR_CALL_NOT_IMPLEMENTED; + } + } + + private static void Report(int state, int controls, int waitHintMs) + { + var status = new SERVICE_STATUS + { + dwServiceType = SERVICE_WIN32_OWN_PROCESS, + dwCurrentState = state, + dwControlsAccepted = controls, + dwWaitHint = waitHintMs, + }; + SetServiceStatus(s_status, ref status); + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool StartServiceCtrlDispatcherW(nint table); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint RegisterServiceCtrlHandlerExW(string name, HandlerExProc handler, nint context); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SetServiceStatus(nint status, ref SERVICE_STATUS serviceStatus); + } + // Pipe protocol (one JSON line per request, one per response). // // {"op":"hello","protocol":1} - // -> {"ok":true,"helper":"satellite-hm-helper","audio":true} + // -> {"ok":true,"helper":"satellite-hm-helper","audio":true,"broker":B} // `audio` says this helper can broker controller-audio rings; a // satellite talking to an older helper simply sees it absent and - // gets no audio fields on plug. + // gets no audio fields on plug. `broker` is true when the answer + // comes from the SatelliteHmBroker service rather than a helper + // satellite spawned (and elevated) itself. // // {"op":"plug","serial":N,"profile":""} // -> {"ok":true,"index":N,"input":H,"inputEvent":H,"companionEvent":H, @@ -139,13 +521,18 @@ private static int Serve(string pipePath, int parentPid) private sealed class Session : IDisposable { private readonly int _parentPid; + private readonly bool _broker; private readonly HMContext _ctx = new(); private readonly Dictionary _controllers = new(); private readonly Dictionary _audio = new(); private bool _initialized; private bool _disposed; - public Session(int parentPid) => _parentPid = parentPid; + public Session(int parentPid, bool broker = false) + { + _parentPid = parentPid; + _broker = broker; + } public string Handle(string line, out bool quit) { @@ -162,6 +549,7 @@ public string Handle(string line, out bool quit) { w.WriteString("helper", "satellite-hm-helper"); w.WriteBoolean("audio", true); + w.WriteBoolean("broker", _broker); }); case "plug": return Plug(root.GetProperty("serial").GetUInt32(), diff --git a/installer.iss b/installer.iss index dba3df9..e05ddb8 100644 --- a/installer.iss +++ b/installer.iss @@ -39,6 +39,13 @@ ; pnputil, no reboot). Satellite runs with either driver, both, or none ; (controller types degrade accordingly). ; +; The same helper is registered as the LocalSystem service SatelliteHmBroker +; (demand-start; the SCM's named-pipe trigger or satellite itself starts it, +; and it exits after five idle minutes). Creating a HIDMaestro device needs +; an elevated token, and the service is what lets the unelevated satellite +; get one without a UAC prompt at plug time. Without it satellite falls back +; to spawning the helper `runas` (one prompt per session). +; ; Setup installs NO kernel driver for HIDMaestro. One runtime feature does: ; controller audio (the emulated DualSense / DualShock 4 v2 presenting its ; own microphone and speaker to Windows) is served over HIDMaestro's @@ -319,6 +326,58 @@ var PreviousDesktopIcon: Boolean; IsFirstInstall: Boolean; +const + BrokerServiceName = 'SatelliteHmBroker'; + BrokerPipeName = 'satellite-hm-broker'; + BrokerServiceSddl = 'D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;WD)'; + +function BrokerServiceExists: Boolean; +begin + Result := RegKeyExists(HKEY_LOCAL_MACHINE, 'SYSTEM\CurrentControlSet\Services\' + BrokerServiceName); +end; + +procedure RunSc(const Args: String); +var + ResultCode: Integer; +begin + Exec(ExpandConstant('{sys}\sc.exe'), Args, '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Log('sc.exe ' + Args + ' -> ' + IntToStr(ResultCode)); +end; + +procedure StopBrokerService; +var + ResultCode: Integer; +begin + if BrokerServiceExists then begin + RunSc('stop ' + BrokerServiceName); + Sleep(1500); + end; + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM satellite-hm-helper.exe', '', SW_HIDE, + ewWaitUntilTerminated, ResultCode); +end; + +procedure RemoveBrokerService; +begin + if not BrokerServiceExists then Exit; + StopBrokerService; + RunSc('delete ' + BrokerServiceName); +end; + +procedure RegisterBrokerService; +var + BinPath: String; +begin + BinPath := '"\"' + ExpandConstant('{app}\satellite-hm-helper.exe') + '\" service"'; + if BrokerServiceExists then + RunSc('config ' + BrokerServiceName + ' binPath= ' + BinPath + ' start= demand obj= LocalSystem DisplayName= "Satellite Controller Broker"') + else + RunSc('create ' + BrokerServiceName + ' binPath= ' + BinPath + ' start= demand obj= LocalSystem DisplayName= "Satellite Controller Broker"'); + RunSc('description ' + BrokerServiceName + ' "Creates Satellite''s virtual HIDMaestro controllers for the signed-in user so Satellite never needs to run elevated. Starts on demand and exits when idle."'); + RunSc('sdset ' + BrokerServiceName + ' ' + BrokerServiceSddl); + RunSc('triggerinfo ' + BrokerServiceName + ' start/namedpipe/' + BrokerPipeName); + RunSc('failure ' + BrokerServiceName + ' reset= 86400 actions= restart/5000/restart/30000//'); +end; + function ShouldShowAutostartTask: Boolean; begin // Show the autostart task ONLY on a first install. On upgrade we @@ -432,6 +491,11 @@ begin Result := True; end; +function ShouldRegisterBroker: Boolean; +begin + Result := ShouldRunHidMaestro; +end; + // Returns True iff we should run the bundled ViGEmBus installer. // Used both by the [Files] Check= (gates extraction) and the post-install // step (gates execution). Both must agree. @@ -487,7 +551,9 @@ begin Result := Result + #13#10 + #13#10 + 'HIDMaestro is a user-mode driver that adds virtual DualSense and' + ' Switch Pro controllers (and covers DualShock 4 / Xbox 360 when' - + ' ViGEmBus is absent). No reboot needed.' + #13#10; + + ' ViGEmBus is absent). No reboot needed. Setup also registers the' + + ' Satellite Controller Broker service so no elevation prompt is' + + ' needed when a controller connects.' + #13#10; if DetectedHm then Result := Result + 'Already deployed here; the bundled v' + '{#HmVersion}' + ' payload will refresh it.' else @@ -551,6 +617,14 @@ const VigemMaxAttempts = 4; VigemRetryDelayMs = 8000; +procedure DriverNotice(const Msg: String; Typ: TMsgBoxType); +begin + if WantsOTARelaunch then + Log('driver notice (OTA, suppressed): ' + Msg) + else + MsgBox(Msg, Typ, MB_OK); +end; + procedure RunBundledViGEm; var InstallerPath: String; @@ -566,11 +640,11 @@ begin repeat Inc(Attempt); if not Exec(InstallerPath, '/quiet /norestart', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin - MsgBox('Could not launch the bundled ViGEmBus installer. Satellite ' + + DriverNotice('Could not launch the bundled ViGEmBus installer. Satellite ' + 'will still install, but you must install ViGEmBus manually ' + 'before connecting a sender.' + #13#10 + #13#10 + 'Get it from: https://github.com/nefarius/ViGEmBus/releases', - mbInformation, MB_OK); + mbInformation); Exit; end; if ResultCode <> VigemBusyCode then @@ -587,23 +661,23 @@ begin 1641, 3010: RebootNeeded := True; 1602: - MsgBox('ViGEmBus installation was cancelled. Install it manually ' + + DriverNotice('ViGEmBus installation was cancelled. Install it manually ' + 'before using virtual gamepads, or re-run the Satellite ' + - 'installer.', mbInformation, MB_OK); + 'installer.', mbInformation); 1603: - MsgBox('ViGEmBus installation failed (fatal error). Check the ' + + DriverNotice('ViGEmBus installation failed (fatal error). Check the ' + 'installer log under %TEMP% and install manually before ' + - 'using virtual gamepads.', mbError, MB_OK); + 'using virtual gamepads.', mbError); VigemBusyCode: - MsgBox('Another installation (often Windows Update) was still in ' + + DriverNotice('Another installation (often Windows Update) was still in ' + 'progress, so ViGEmBus could not be installed right now. ' + 'Let any pending updates finish, then re-run the Satellite ' + - 'installer to add the driver.', mbInformation, MB_OK); + 'installer to add the driver.', mbInformation); else begin Msg := 'ViGEmBus installer returned exit code ' + IntToStr(ResultCode) + '.' + #13#10 + 'Satellite will still install, but virtual gamepad output ' + 'may not work until ViGEmBus is installed manually.'; - MsgBox(Msg, mbError, MB_OK); + DriverNotice(Msg, mbError); end; end; end; @@ -618,18 +692,24 @@ begin WizardForm.FilenameLabel.Caption := '{#HmHelperExe}'; if not Exec(HelperPath, 'install-driver', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin - MsgBox('Could not launch the HIDMaestro helper. Satellite will still ' + + DriverNotice('Could not launch the HIDMaestro helper. Satellite will still ' + 'install; DualSense / Switch Pro controller types stay ' + 'unavailable until the driver is deployed (re-run this ' + - 'installer to retry).', mbInformation, MB_OK); + 'installer to retry).', mbInformation); Exit; end; if ResultCode <> 0 then - MsgBox('HIDMaestro driver deployment returned exit code ' + + DriverNotice('HIDMaestro driver deployment returned exit code ' + IntToStr(ResultCode) + '. Satellite will still install; ' + 'DualSense / Switch Pro controller types stay unavailable ' + 'until the driver is deployed (check the setup log under ' + - '%TEMP% and re-run this installer to retry).', mbInformation, MB_OK); + '%TEMP% and re-run this installer to retry).', mbInformation); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + StopBrokerService; end; procedure CurStepChanged(CurStep: TSetupStep); @@ -639,6 +719,10 @@ begin RunBundledViGEm; if ShouldRunHidMaestro then RunHidMaestroDeploy; + if ShouldRegisterBroker then + RegisterBrokerService + else + RemoveBrokerService; end; function NeedRestart: Boolean; @@ -824,8 +908,9 @@ end; procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); begin - if CurUninstallStep = usUninstall then - MaybeRemoveHidMaestro - else if CurUninstallStep = usPostUninstall then + if CurUninstallStep = usUninstall then begin + RemoveBrokerService; + MaybeRemoveHidMaestro; + end else if CurUninstallStep = usPostUninstall then MaybeRemoveVigem; end; diff --git a/redist/README.md b/redist/README.md index 7c1620a..bffa79a 100644 --- a/redist/README.md +++ b/redist/README.md @@ -111,3 +111,18 @@ new release's `driver/driver.h` and `SharedMemoryIO.cs` against the layout constants in `src/platform/windows/hidmaestro_wire.h` (`test_hidmaestro_wire` pins them) — the shared-memory protocol carries no version field, so the pin bump IS the compatibility review. + +Either bump also updates `src/platform/windows/driver_pins.h`, which is what +the dashboard's driver banner compares the installed drivers against +(`versionState` in `/api/backend/status`). `version-consistency.yml` fails if +the `ViGEmBusVersion` / `HmVersion` defines in `installer.iss` drift from it. +The HIDMaestro UMDF driver is versioned separately from the SDK release +(1.7.0 embeds driver `1.4.7.12`); read the embedded INF version straight out +of the staged assembly rather than guessing: + +```powershell +Select-String -Path redist/hidmaestro/HIDMaestro.Core.dll -Pattern 'DriverVer\s*=\s*[0-9/]+,\s*([0-9.]+)' -AllMatches | + ForEach-Object { $_.Matches } | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique +``` + +and put that value in `SATELLITE_HIDMAESTRO_BUNDLED_DRIVER_VERSION`. diff --git a/scripts/test-broker-service.ps1 b/scripts/test-broker-service.ps1 new file mode 100644 index 0000000..4b7e308 --- /dev/null +++ b/scripts/test-broker-service.ps1 @@ -0,0 +1,125 @@ +<# +.SYNOPSIS + Registers the SatelliteHmBroker service against a built helper, proves it + hosts in session 0 and answers on its pipe, then removes it again. + +.DESCRIPTION + Exercises the parts of the elevation-free HIDMaestro path that only a + real Service Control Manager can: the advapi32 dispatcher in + `satellite-hm-helper.exe service`, the named-pipe trigger, the SDDL that + grants interactive users SERVICE_START, the pipe DACL, and the broker's + client authorization (this script is not satellite.exe, so the expected + answer to `hello` is a refusal naming that). Cleans up after itself unless + -Keep is passed, in which case the service stays registered so a running + satellite.exe beside the helper can be tested with a real controller plug. + + Requires an elevated PowerShell (sc.exe create/delete need it). + +.PARAMETER Helper + Path to satellite-hm-helper.exe. Defaults to the dotnet publish output. + +.PARAMETER Keep + Leave the service registered after the checks. +#> +[CmdletBinding()] +param( + [string]$Helper = (Join-Path $PSScriptRoot '..\helper\hidmaestro\bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\satellite-hm-helper.exe'), + [switch]$Keep +) + +$ErrorActionPreference = 'Stop' +$svc = 'SatelliteHmBroker' +$pipe = 'satellite-hm-broker' +$sddl = 'D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;WD)' + +$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run from an elevated PowerShell: registering a service needs it.' +} +$Helper = (Resolve-Path $Helper).Path + +$failures = [System.Collections.Generic.List[string]]::new() +function Assert([bool]$Condition, [string]$What) { + if ($Condition) { " ok: $What" } else { $failures.Add($What); Write-Warning "FAIL: $What" } +} + +function Invoke-Hello { + $c = New-Object System.IO.Pipes.NamedPipeClientStream('.', $pipe, [System.IO.Pipes.PipeDirection]::InOut) + try { + $c.Connect(15000) + $w = New-Object System.IO.StreamWriter($c) + $w.AutoFlush = $true + $w.Write('{"op":"hello","protocol":1}' + [char]10) + $r = New-Object System.IO.StreamReader($c) + return $r.ReadLine() + } + finally { $c.Dispose() } +} + +try { + 'step 1: register' + if (Get-Service -Name $svc -ErrorAction SilentlyContinue) { + & sc.exe stop $svc | Out-Null + Start-Sleep -Seconds 2 + & sc.exe delete $svc | Out-Null + Start-Sleep -Seconds 1 + } + & sc.exe create $svc binPath= "`"$Helper`" service" start= demand obj= LocalSystem DisplayName= 'Satellite Controller Broker' | Out-Null + Assert ($null -ne (Get-Service -Name $svc -ErrorAction SilentlyContinue)) 'service registered' + & sc.exe sdset $svc $sddl | Out-Null + & sc.exe triggerinfo $svc "start/namedpipe/$pipe" | Out-Null + $trig = (& sc.exe qtriggerinfo $svc) -join ' ' + Assert ($trig -match [regex]::Escape($pipe)) 'named-pipe start trigger recorded' + + 'step 2: explicit start hosts in session 0' + & sc.exe start $svc | Out-Null + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline -and (Get-Service $svc).Status -ne 'Running') { Start-Sleep -Milliseconds 250 } + Assert ((Get-Service $svc).Status -eq 'Running') 'service reaches Running' + $proc = Get-Process -Name 'satellite-hm-helper' -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq 0 } | Select-Object -First 1 + Assert ($null -ne $proc) 'helper process runs in session 0' + + 'step 3: pipe answers, and refuses a client that is not satellite.exe' + $reply = Invoke-Hello + " reply: $reply" + Assert ($reply -like '*"ok":false*' -and $reply -like '*not satellite.exe*') 'hello refused with the image-path reason' + + 'step 4: a second connection is served (pipe instance re-created)' + $reply2 = Invoke-Hello + Assert ($reply2 -like '*not satellite.exe*') 'second connection answered' + + 'step 5: stop, then let the named-pipe trigger start it' + & sc.exe stop $svc | Out-Null + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline -and (Get-Service $svc).Status -ne 'Stopped') { Start-Sleep -Milliseconds 250 } + Assert ((Get-Service $svc).Status -eq 'Stopped') 'service stops on request' + $triggered = $null + try { $triggered = Invoke-Hello } catch { " trigger connect: $($_.Exception.Message)" } + Start-Sleep -Seconds 1 + Assert ((Get-Service $svc).Status -eq 'Running') 'connecting to the pipe started the service (trigger)' + if ($null -eq $triggered) { + try { $triggered = Invoke-Hello } catch { " retry connect: $($_.Exception.Message)" } + } + Assert ($triggered -like '*not satellite.exe*') 'triggered service answers on its pipe' + + 'step 6: interactive users hold SERVICE_START (sdset)' + $sd = (& sc.exe sdshow $svc) -join '' + Assert ($sd -match '\(A;;[A-Z]*RP[A-Z]*;;;IU\)') 'SDDL grants IU the RP (start) right' +} +finally { + if (-not $Keep) { + & sc.exe stop $svc 2>$null | Out-Null + Start-Sleep -Seconds 2 + & sc.exe delete $svc 2>$null | Out-Null + Get-Process -Name 'satellite-hm-helper' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + 'cleanup: service removed' + } + else { + "kept: $svc stays registered against $Helper" + } +} + +if ($failures.Count -gt 0) { + throw "Broker service test failed: $($failures.Count) assertion(s): $($failures -join '; ')" +} +'Broker service: all assertions passed.' diff --git a/scripts/test-installer-roundtrip.ps1 b/scripts/test-installer-roundtrip.ps1 index 51fd478..f48c38a 100644 --- a/scripts/test-installer-roundtrip.ps1 +++ b/scripts/test-installer-roundtrip.ps1 @@ -83,6 +83,7 @@ try { Assert (Test-Path (Join-Path $installDir 'satellite.exe')) 'satellite.exe installed' Assert (Test-Path (Join-Path $installDir 'satellite-hm-helper.exe')) 'HIDMaestro helper installed' Assert ($null -ne (Get-ChildItem -Path $installDir -Recurse -Filter 'dashboard.js' -ErrorAction SilentlyContinue | Select-Object -First 1)) 'web UI assets installed' + Assert ($null -eq (Get-Service -Name 'SatelliteHmBroker' -ErrorAction SilentlyContinue)) 'broker service not registered under /HIDMAESTRO=skip' $unins = Get-ChildItem -Path $installDir -Filter 'unins*.exe' -ErrorAction SilentlyContinue Assert ($null -ne $unins -and $unins.Count -ge 1) 'uninstaller present' @@ -134,6 +135,7 @@ try { Assert (-not (Test-Path (Join-Path $installDir 'satellite.exe'))) 'satellite.exe removed' Assert (-not (Test-Path $installDir)) 'install directory removed' Assert ($null -eq (Get-ArpKey)) 'ARP key removed' + Assert ($null -eq (Get-Service -Name 'SatelliteHmBroker' -ErrorAction SilentlyContinue)) 'broker service absent after uninstall' } finally { if (Test-Path $installDir) { diff --git a/src/core/backend_registry.cpp b/src/core/backend_registry.cpp index 5828f49..d7ff873 100644 --- a/src/core/backend_registry.cpp +++ b/src/core/backend_registry.cpp @@ -2,7 +2,8 @@ #include "core/backend_registry.h" #include "core/gamepad_backend.h" // BACKEND_ID_* -#include "core/types.h" // CONTROLLER_TYPE_*, controllerTypeName +#include "core/semver.h" +#include "core/types.h" // CONTROLLER_TYPE_*, controllerTypeName namespace satellite { @@ -116,6 +117,15 @@ const BackendDescriptor* backendDescriptorById(const std::string& id) { return nullptr; } +const char* driverVersionState(const std::string& driverVersion, + const std::string& bundledVersion) { + if (driverVersion.empty() || bundledVersion.empty()) return DRIVER_VERSION_STATE_UNKNOWN; + const int c = compareDottedVersion(driverVersion, bundledVersion); + if (c < 0) return DRIVER_VERSION_STATE_OUTDATED; + if (c > 0) return DRIVER_VERSION_STATE_NEWER; + return DRIVER_VERSION_STATE_CURRENT; +} + bool backendCanCarryAudio(const BackendDescriptor& d) { for (size_t i = 0; i < d.supportCount; ++i) { if (d.support[i].mic || d.support[i].speaker) return true; @@ -153,6 +163,12 @@ std::string buildBackendsJson(const std::vector& statuses, appendNullable(json, d->eolDate); json += ",\"driverVersion\":"; appendNullable(json, st.driverVersion.c_str()); + json += ",\"bundledVersion\":"; + appendNullable(json, st.bundledVersion.c_str()); + json += ",\"versionState\":\""; + json += driverVersionState(st.driverVersion, st.bundledVersion); + json += "\",\"restartPending\":"; + json += st.restartPending ? "true" : "false"; json += ",\"controllers\":["; for (size_t i = 0; i < d->supportCount; ++i) { if (i != 0) json += ","; diff --git a/src/core/backend_registry.h b/src/core/backend_registry.h index faafd6a..2e548d1 100644 --- a/src/core/backend_registry.h +++ b/src/core/backend_registry.h @@ -143,14 +143,25 @@ struct BackendRuntimeStatus { bool available = false; std::string errorCode; // empty when available std::string driverVersion; + std::string bundledVersion; + bool restartPending = false; BackendRuntimeStatus() = default; BackendRuntimeStatus(std::string id_, bool available_, std::string errorCode_ = std::string(), - std::string driverVersion_ = std::string()) + std::string driverVersion_ = std::string(), + std::string bundledVersion_ = std::string(), bool restartPending_ = false) : id(std::move(id_)), available(available_), errorCode(std::move(errorCode_)), - driverVersion(std::move(driverVersion_)) {} + driverVersion(std::move(driverVersion_)), bundledVersion(std::move(bundledVersion_)), + restartPending(restartPending_) {} }; +inline const char* DRIVER_VERSION_STATE_CURRENT = "current"; +inline const char* DRIVER_VERSION_STATE_OUTDATED = "outdated"; +inline const char* DRIVER_VERSION_STATE_NEWER = "newer"; +inline const char* DRIVER_VERSION_STATE_UNKNOWN = "unknown"; + +const char* driverVersionState(const std::string& driverVersion, const std::string& bundledVersion); + // True when any controller type this backend serves has audio endpoints, i.e. // when the `controllerAudio` setting can change what this backend materializes. bool backendCanCarryAudio(const BackendDescriptor& d); @@ -160,7 +171,7 @@ bool backendCanCarryAudio(const BackendDescriptor& d); // identity/latency/features come from the registry. Ids with no descriptor are // skipped. Each element: // {"id","vendor","displayName","kernelMode","audio","available","errorCode", -// "lifecycle","eolDate","driverVersion", +// "lifecycle","eolDate","driverVersion","bundledVersion","versionState","restartPending", // "controllers":[{"type","name","latency","latencyRank","motion","touchpad", // "lightbar","motionRequires","submitLatency"}, ...]} // `audio` is the only runtime-switched field on the element: the per-controller diff --git a/src/core/driver_inf.h b/src/core/driver_inf.h new file mode 100644 index 0000000..180b972 --- /dev/null +++ b/src/core/driver_inf.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma once + +#include +#include + +namespace satellite { + +inline std::string infTextToNarrow(const std::string& bytes) { + if (bytes.size() >= 2 && static_cast(bytes[0]) == 0xFF && + static_cast(bytes[1]) == 0xFE) { + std::string out; + out.reserve(bytes.size() / 2); + for (size_t i = 2; i + 1 < bytes.size(); i += 2) { + out.push_back(bytes[i + 1] == 0 ? bytes[i] : '?'); + } + return out; + } + if (bytes.size() >= 3 && static_cast(bytes[0]) == 0xEF && + static_cast(bytes[1]) == 0xBB && + static_cast(bytes[2]) == 0xBF) { + return bytes.substr(3); + } + return bytes; +} + +inline std::string parseInfDriverVersion(const std::string& infBytes) { + const std::string text = infTextToNarrow(infBytes); + size_t pos = 0; + while (pos < text.size()) { + size_t eol = text.find('\n', pos); + if (eol == std::string::npos) eol = text.size(); + std::string line = text.substr(pos, eol - pos); + pos = eol + 1; + + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) i++; + if (line.compare(i, 9, "DriverVer") != 0) continue; + i += 9; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) i++; + if (i >= line.size() || line[i] != '=') continue; + i++; + size_t comma = line.find(',', i); + if (comma == std::string::npos) continue; + size_t v = comma + 1; + while (v < line.size() && (line[v] == ' ' || line[v] == '\t')) v++; + size_t e = v; + while (e < line.size() && ((line[e] >= '0' && line[e] <= '9') || line[e] == '.')) e++; + if (e == v || line[v] == '.') continue; + return line.substr(v, e - v); + } + return ""; +} + +} // namespace satellite diff --git a/src/core/semver.h b/src/core/semver.h index c565f3e..1958e1d 100644 --- a/src/core/semver.h +++ b/src/core/semver.h @@ -88,4 +88,17 @@ inline int compareSemver(const std::string& a, const std::string& b) { return semverComparePrerelease(aPre, bPre); } +inline int compareDottedVersion(const std::string& a, const std::string& b) { + std::vector av = semverSplitDots(a); + std::vector bv = semverSplitDots(b); + size_t n = av.size() > bv.size() ? av.size() : bv.size(); + for (size_t i = 0; i < n; i++) { + std::string ax = i < av.size() && semverIdentIsNumeric(av[i]) ? av[i] : "0"; + std::string bx = i < bv.size() && semverIdentIsNumeric(bv[i]) ? bv[i] : "0"; + int c = semverCompareNumericIdent(ax, bx); + if (c != 0) return c; + } + return 0; +} + } // namespace satellite diff --git a/src/platform/windows/driver_pins.h b/src/platform/windows/driver_pins.h new file mode 100644 index 0000000..ebf406e --- /dev/null +++ b/src/platform/windows/driver_pins.h @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma once + +#define SATELLITE_VIGEMBUS_BUNDLED_VERSION "1.22.0" +#define SATELLITE_HIDMAESTRO_SDK_VERSION "1.7.0" +#define SATELLITE_HIDMAESTRO_BUNDLED_DRIVER_VERSION "1.4.7.12" diff --git a/src/platform/windows/gamepad_backend.cpp b/src/platform/windows/gamepad_backend.cpp index ed7aa69..64371d1 100644 --- a/src/platform/windows/gamepad_backend.cpp +++ b/src/platform/windows/gamepad_backend.cpp @@ -1,8 +1,65 @@ // SPDX-License-Identifier: LGPL-3.0-or-later #include "core/gamepad_backend.h" +#include "driver_pins.h" #include "hidmaestro_helper_client.h" #include "vigem.h" +#include + +static std::string vigemBusDriverFilePath() { + wchar_t sysDir[MAX_PATH]; + const UINT n = GetSystemDirectoryW(sysDir, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return ""; + std::wstring w(sysDir, n); + w += L"\\drivers\\ViGEmBus.sys"; + const int len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr); + if (len <= 0) return ""; + std::string out(static_cast(len - 1), '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, out.data(), len, nullptr, nullptr); + return out; +} + +static std::string installedVigemBusVersion() { + const std::string path = vigemBusDriverFilePath(); + if (path.empty()) return ""; + DWORD handle = 0; + const DWORD size = GetFileVersionInfoSizeA(path.c_str(), &handle); + if (size == 0) return ""; + std::vector buf(size); + if (!GetFileVersionInfoA(path.c_str(), 0, size, buf.data())) return ""; + VS_FIXEDFILEINFO* ffi = nullptr; + UINT ffiLen = 0; + if (!VerQueryValueA(buf.data(), "\\", reinterpret_cast(&ffi), &ffiLen) || + ffi == nullptr || ffiLen < sizeof(VS_FIXEDFILEINFO)) { + return ""; + } + char v[48]; + std::snprintf(v, sizeof(v), "%u.%u.%u.%u", static_cast(HIWORD(ffi->dwFileVersionMS)), + static_cast(LOWORD(ffi->dwFileVersionMS)), + static_cast(HIWORD(ffi->dwFileVersionLS)), + static_cast(LOWORD(ffi->dwFileVersionLS))); + return v; +} + +static bool vigemBusRestartPending() { + HDEVINFO devInfo = SetupDiGetClassDevsW(&GUID_DEVINTERFACE_BUSENUM_VIGEM, nullptr, nullptr, + DIGCF_DEVICEINTERFACE); + if (devInfo == INVALID_HANDLE_VALUE) return false; + bool pending = false; + SP_DEVINFO_DATA dev{}; + dev.cbSize = sizeof(dev); + for (DWORD i = 0; SetupDiEnumDeviceInfo(devInfo, i, &dev); ++i) { + ULONG status = 0, problem = 0; + if (CM_Get_DevNode_Status(&status, &problem, dev.DevInst, 0) != CR_SUCCESS) continue; + if ((status & DN_NEED_RESTART) != 0 || problem == CM_PROB_NEED_RESTART) { + pending = true; + break; + } + } + SetupDiDestroyDeviceInfoList(devInfo); + return pending; +} + static bool isViGEmDeviceInterfacePresent() { HDEVINFO devInfo = SetupDiGetClassDevsW(&GUID_DEVINTERFACE_BUSENUM_VIGEM, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); @@ -77,9 +134,13 @@ std::vector enumerateBackends() { std::vector out; BackendStatus vigem = probeViGEm(); out.push_back({BACKEND_ID_VIGEM, vigem.available, - vigem.errorCode ? std::string(vigem.errorCode) : std::string()}); + vigem.errorCode ? std::string(vigem.errorCode) : std::string(), + installedVigemBusVersion(), SATELLITE_VIGEMBUS_BUNDLED_VERSION, + vigemBusRestartPending()}); BackendStatus hm = probeHidMaestro(); out.push_back({BACKEND_ID_HIDMAESTRO, hm.available, - hm.errorCode ? std::string(hm.errorCode) : std::string()}); + hm.errorCode ? std::string(hm.errorCode) : std::string(), + satellite::hidmaestro::installedDriverVersion(), + SATELLITE_HIDMAESTRO_BUNDLED_DRIVER_VERSION, false}); return out; } diff --git a/src/platform/windows/hidmaestro_helper_client.cpp b/src/platform/windows/hidmaestro_helper_client.cpp index 2e7539b..32c5f81 100644 --- a/src/platform/windows/hidmaestro_helper_client.cpp +++ b/src/platform/windows/hidmaestro_helper_client.cpp @@ -1,11 +1,16 @@ // SPDX-License-Identifier: LGPL-3.0-or-later #include "hidmaestro_helper_client.h" +#include "app/app_state.h" +#include "core/driver_inf.h" #include "core/json.h" +#include "core/semver.h" #include "hidmaestro_report.h" #include +#include +#include #include namespace satellite { @@ -17,6 +22,9 @@ namespace { // the connect budget additionally covers a user hesitating at the UAC prompt. constexpr DWORD kConnectTimeoutMs = 120000; constexpr DWORD kRequestTimeoutMs = 120000; +constexpr DWORD kBrokerConnectTimeoutMs = 10000; +constexpr const wchar_t* kBrokerServiceName = L"SatelliteHmBroker"; +constexpr const wchar_t* kBrokerPipe = L"\\\\.\\pipe\\satellite-hm-broker"; std::wstring modulePathDirFile(const wchar_t* filename) { wchar_t buf[MAX_PATH]; @@ -28,19 +36,53 @@ std::wstring modulePathDirFile(const wchar_t* filename) { return path.substr(0, slash + 1) + filename; } -bool driverStorePresent() { +std::wstring driverStoreRepository() { wchar_t sysRoot[MAX_PATH]; const UINT n = GetSystemWindowsDirectoryW(sysRoot, MAX_PATH); - if (n == 0 || n >= MAX_PATH) return false; - const std::wstring repo = - std::wstring(sysRoot, n) + L"\\System32\\DriverStore\\FileRepository\\hidmaestro.inf_*"; + if (n == 0 || n >= MAX_PATH) return L""; + return std::wstring(sysRoot, n) + L"\\System32\\DriverStore\\FileRepository\\"; +} + +bool driverStorePresent() { + const std::wstring repo = driverStoreRepository(); + if (repo.empty()) return false; WIN32_FIND_DATAW fd; - HANDLE find = FindFirstFileW(repo.c_str(), &fd); + HANDLE find = FindFirstFileW((repo + L"hidmaestro.inf_*").c_str(), &fd); if (find == INVALID_HANDLE_VALUE) return false; FindClose(find); return true; } +DWORD brokerServicePid(bool tryStart) { + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (scm == nullptr) return 0; + DWORD access = SERVICE_QUERY_STATUS | (tryStart ? SERVICE_START : 0); + SC_HANDLE svc = OpenServiceW(scm, kBrokerServiceName, access); + if (svc == nullptr && tryStart) + svc = OpenServiceW(scm, kBrokerServiceName, SERVICE_QUERY_STATUS); + if (svc == nullptr) { + CloseServiceHandle(scm); + return 0; + } + SERVICE_STATUS_PROCESS ssp{}; + DWORD needed = 0; + DWORD pid = 0; + if (QueryServiceStatusEx(svc, SC_STATUS_PROCESS_INFO, reinterpret_cast(&ssp), + sizeof(ssp), &needed)) { + if (ssp.dwCurrentState == SERVICE_STOPPED && tryStart) StartServiceW(svc, 0, nullptr); + if (ssp.dwCurrentState == SERVICE_RUNNING) pid = ssp.dwProcessId; + } + CloseServiceHandle(svc); + CloseServiceHandle(scm); + return pid; +} + +std::string readFileBytes(const std::wstring& path) { + std::ifstream f(path.c_str(), std::ios::binary); + if (!f.is_open()) return ""; + return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); +} + } // namespace std::wstring helperBinaryPath() { return modulePathDirFile(L"satellite-hm-helper.exe"); } @@ -50,6 +92,16 @@ bool helperBinaryPresent() { return !path.empty() && GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES; } +bool brokerServiceRegistered() { + SC_HANDLE scm = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (scm == nullptr) return false; + SC_HANDLE svc = OpenServiceW(scm, kBrokerServiceName, SERVICE_QUERY_STATUS); + const bool exists = svc != nullptr; + if (svc != nullptr) CloseServiceHandle(svc); + CloseServiceHandle(scm); + return exists; +} + bool driverInstalled() { HKEY key; if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\HIDMaestro", 0, KEY_READ | KEY_WOW64_64KEY, @@ -60,6 +112,24 @@ bool driverInstalled() { return driverStorePresent(); } +std::string installedDriverVersion() { + const std::wstring repo = driverStoreRepository(); + if (repo.empty()) return ""; + WIN32_FIND_DATAW fd; + HANDLE find = FindFirstFileW((repo + L"hidmaestro.inf_*").c_str(), &fd); + if (find == INVALID_HANDLE_VALUE) return ""; + std::string best; + do { + if ((fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) continue; + const std::string v = + parseInfDriverVersion(readFileBytes(repo + fd.cFileName + L"\\hidmaestro.inf")); + if (v.empty()) continue; + if (best.empty() || compareDottedVersion(v, best) > 0) best = v; + } while (FindNextFileW(find, &fd)); + FindClose(find); + return best; +} + HelperClient::~HelperClient() { shutdown(); } bool HelperClient::installed() const { return helperBinaryPresent() && driverInstalled(); } @@ -84,6 +154,71 @@ bool HelperClient::startLocked() { const std::wstring helper = helperBinaryPath(); if (helper.empty() || GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES) return false; + if (connectBrokerLocked()) { + logMsg(LogLevel::INFO, "hidmaestro", "Connected to the SatelliteHmBroker service"); + return true; + } + if (!spawnHelperLocked()) return false; + logMsg(LogLevel::INFO, "hidmaestro", "Spawned an elevated helper for this session"); + return true; +} + +bool HelperClient::connectBrokerLocked() { + if (!brokerServiceRegistered()) return false; + + HANDLE pipe = INVALID_HANDLE_VALUE; + const ULONGLONG deadline = GetTickCount64() + kBrokerConnectTimeoutMs; + bool startAttempted = false; + while (true) { + pipe = CreateFileW(kBrokerPipe, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, nullptr); + if (pipe != INVALID_HANDLE_VALUE) break; + const DWORD err = GetLastError(); + if (err == ERROR_PIPE_BUSY) { + WaitNamedPipeW(kBrokerPipe, 1000); + } else if (err == ERROR_FILE_NOT_FOUND) { + if (!startAttempted) { + brokerServicePid(/*tryStart=*/true); + startAttempted = true; + } + Sleep(250); + } else { + return false; + } + if (GetTickCount64() >= deadline) return false; + } + + ULONG serverPid = 0; + if (!GetNamedPipeServerProcessId(pipe, &serverPid) || serverPid == 0 || + serverPid != brokerServicePid(/*tryStart=*/false)) { + CloseHandle(pipe); + return false; + } + + HANDLE ioEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (ioEvent == nullptr) { + CloseHandle(pipe); + return false; + } + pipe_ = pipe; + ioEvent_ = ioEvent; + helperProcess_ = nullptr; + if (!helloLocked()) { + stopLocked(false); + return false; + } + return true; +} + +bool HelperClient::helloLocked() { + std::string response; + if (!requestLocked("{\"op\":\"hello\",\"protocol\":1}", response)) return false; + Json j; + return jsonParse(response, j) && jsonBool(j, "ok"); +} + +bool HelperClient::spawnHelperLocked() { + const std::wstring helper = helperBinaryPath(); // Unguessable per-session pipe name; the connecting client's PID is // verified against the process we spawned before any request is sent. @@ -161,13 +296,7 @@ bool HelperClient::startLocked() { ioEvent_ = ioEvent; helperProcess_ = sei.hProcess; - std::string response; - if (!requestLocked("{\"op\":\"hello\",\"protocol\":1}", response)) { - stopLocked(false); - return false; - } - Json j; - if (!jsonParse(response, j) || !jsonBool(j, "ok")) { + if (!helloLocked()) { stopLocked(false); return false; } @@ -178,7 +307,7 @@ void HelperClient::stopLocked(bool sendShutdown) { if (pipe_ != INVALID_HANDLE_VALUE && sendShutdown) { std::string response; requestLocked("{\"op\":\"shutdown\"}", response); - WaitForSingleObject(helperProcess_, 5000); + if (helperProcess_) WaitForSingleObject(helperProcess_, 5000); } if (pipe_ != INVALID_HANDLE_VALUE) CloseHandle(pipe_); pipe_ = INVALID_HANDLE_VALUE; diff --git a/src/platform/windows/hidmaestro_helper_client.h b/src/platform/windows/hidmaestro_helper_client.h index e9cab3d..0cc10aa 100644 --- a/src/platform/windows/hidmaestro_helper_client.h +++ b/src/platform/windows/hidmaestro_helper_client.h @@ -4,7 +4,9 @@ // (satellite-hm-helper.exe). Satellite stays asInvoker: the helper carries the // HIDMaestro SDK (driver install, SwDevice + Global\ section creation — all of // which need an elevated token) and hands duplicated section/event handles -// back over a private named pipe, one JSON line per request. Spawning it +// back over a named pipe, one JSON line per request. Two ways to reach it, in +// order: the SatelliteHmBroker service setup registers (LocalSystem, demand- +// start, well-known pipe; no prompt), else a helper spawned `runas`, which // shows one UAC prompt per session, deferred to the first HIDMaestro plug. #pragma once @@ -26,6 +28,8 @@ bool helperBinaryPresent(); // Driver footprint: HKLM\SOFTWARE\HIDMaestro (written after a successful // deploy) or the hidmaestro.inf_* driver-store directories. Non-admin. bool driverInstalled(); +std::string installedDriverVersion(); +bool brokerServiceRegistered(); class HelperClient : public IHidMaestroProvisioner { public: @@ -42,6 +46,9 @@ class HelperClient : public IHidMaestroProvisioner { private: bool startLocked(); + bool connectBrokerLocked(); + bool spawnHelperLocked(); + bool helloLocked(); void stopLocked(bool sendShutdown); bool requestLocked(const std::string& line, std::string& response); diff --git a/src/platform/windows/updater_adapter.cpp b/src/platform/windows/updater_adapter.cpp index b046524..bf0136d 100644 --- a/src/platform/windows/updater_adapter.cpp +++ b/src/platform/windows/updater_adapter.cpp @@ -529,7 +529,7 @@ bool WindowsUpdaterAdapter::applyUpdate(const std::string& localPath, const Upda sei.fMask = SEE_MASK_NOASYNC; sei.lpVerb = "open"; sei.lpFile = localPath.c_str(); - sei.lpParameters = "/VERYSILENT /OTA /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS"; + sei.lpParameters = "/VERYSILENT /NORESTART /OTA /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS"; sei.nShow = SW_SHOWNORMAL; if (!ShellExecuteExA(&sei)) { diff --git a/tests/test_backend_registry.cpp b/tests/test_backend_registry.cpp index 386c81b..818e35d 100644 --- a/tests/test_backend_registry.cpp +++ b/tests/test_backend_registry.cpp @@ -572,8 +572,64 @@ static void test_buildBackendsJson_parses_and_agrees_with_itself() { EXPECT_EQ(hmDs4["motionRequires"].get(), std::string("hidmaestro>=1.7")); } +static void test_driverVersionState_derivation() { + TEST("driverVersionState: both sides known compare numerically"); + EXPECT_EQ(std::string(driverVersionState("1.22.0.0", "1.22.0")), + std::string(DRIVER_VERSION_STATE_CURRENT)); + EXPECT_EQ(std::string(driverVersionState("1.21.442.0", "1.22.0")), + std::string(DRIVER_VERSION_STATE_OUTDATED)); + EXPECT_EQ(std::string(driverVersionState("1.4.7.12", "1.4.7.12")), + std::string(DRIVER_VERSION_STATE_CURRENT)); + EXPECT_EQ(std::string(driverVersionState("1.4.8.0", "1.4.7.12")), + std::string(DRIVER_VERSION_STATE_NEWER)); + + TEST("driverVersionState: an unread or unpinned side is unknown, never inferred"); + EXPECT_EQ(std::string(driverVersionState("", "1.22.0")), + std::string(DRIVER_VERSION_STATE_UNKNOWN)); + EXPECT_EQ(std::string(driverVersionState("1.22.0", "")), + std::string(DRIVER_VERSION_STATE_UNKNOWN)); + EXPECT_EQ(std::string(driverVersionState("", "")), std::string(DRIVER_VERSION_STATE_UNKNOWN)); +} + +static void test_buildBackendsJson_driver_version_fields() { + TEST("buildBackendsJson: driverVersion/bundledVersion/versionState/restartPending ride each " + "entry"); + std::vector statuses = { + {BACKEND_ID_VIGEM, true, "", "1.21.442.0", "1.22.0", true}, + {BACKEND_ID_HIDMAESTRO, true, "", "1.4.7.12", "1.4.7.12", false}, + }; + Json j = Json::parse(buildBackendsJson(statuses, true)); + EXPECT(j.is_array() && j.size() == 2); + const Json& v = j[0]; + EXPECT_EQ(v["driverVersion"].get(), std::string("1.21.442.0")); + EXPECT_EQ(v["bundledVersion"].get(), std::string("1.22.0")); + EXPECT_EQ(v["versionState"].get(), std::string("outdated")); + EXPECT_EQ(v["restartPending"].get(), true); + const Json& hm = j[1]; + EXPECT_EQ(hm["driverVersion"].get(), std::string("1.4.7.12")); + EXPECT_EQ(hm["versionState"].get(), std::string("current")); + EXPECT_EQ(hm["restartPending"].get(), false); + + TEST("buildBackendsJson: a host that read nothing reports nulls and unknown"); + Json u = Json::parse(buildBackendsJson({{BACKEND_ID_UINPUT, true, ""}}, true)); + EXPECT(u[0]["driverVersion"].is_null()); + EXPECT(u[0]["bundledVersion"].is_null()); + EXPECT_EQ(u[0]["versionState"].get(), std::string("unknown")); + EXPECT_EQ(u[0]["restartPending"].get(), false); + + TEST("buildBackendsJson: a missing driver still carries the bundled version it would get"); + Json m = Json::parse(buildBackendsJson( + {{BACKEND_ID_VIGEM, false, "DRIVER_MISSING", "", "1.22.0", false}}, true)); + EXPECT(m[0]["driverVersion"].is_null()); + EXPECT_EQ(m[0]["bundledVersion"].get(), std::string("1.22.0")); + EXPECT_EQ(m[0]["versionState"].get(), std::string("unknown")); + EXPECT_EQ(m[0]["errorCode"].get(), std::string("DRIVER_MISSING")); +} + int main() { test_latencyTier_names_and_ranks(); + test_driverVersionState_derivation(); + test_buildBackendsJson_driver_version_fields(); test_buildBackendsJson_parses_and_agrees_with_itself(); test_estimateCost_matches_the_documented_table(); test_derived_tier_separates_rows_within_one_backend(); diff --git a/tests/test_driver_inf.cpp b/tests/test_driver_inf.cpp new file mode 100644 index 0000000..95df9c9 --- /dev/null +++ b/tests/test_driver_inf.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#include "../src/core/driver_inf.h" + +#include +#include + +#include "test_util.h" + +using satellite::infTextToNarrow; +using satellite::parseInfDriverVersion; + +static std::string utf16le(const std::string& ascii) { + std::string out("\xFF\xFE", 2); + for (char c : ascii) { + out.push_back(c); + out.push_back('\0'); + } + return out; +} + +static void testPlainInf() { + TEST("DriverVer line with aligned spacing parses the version"); + const std::string inf = "[Version]\r\nSignature = \"$WINDOWS NT$\"\r\nClass = " + "HIDClass\r\nDriverVer = 08/21/2026,1.4.7.12\r\nPnpLockdown = 1\r\n"; + EXPECT_EQ(parseInfDriverVersion(inf), std::string("1.4.7.12")); + + TEST("DriverVer without spaces around = parses"); + EXPECT_EQ(parseInfDriverVersion("DriverVer=08/30/2022,1.21.442.0\n"), + std::string("1.21.442.0")); + + TEST("spaces after the comma are skipped"); + EXPECT_EQ(parseInfDriverVersion("DriverVer = 01/01/2020, 2.0.1.0"), std::string("2.0.1.0")); + + TEST("a trailing comment or CR does not leak into the version"); + EXPECT_EQ(parseInfDriverVersion("DriverVer = 01/01/2020,3.1.0.5 ; pinned\r\n"), + std::string("3.1.0.5")); +} + +static void testEncodings() { + TEST("UTF-8 BOM is stripped"); + EXPECT_EQ(parseInfDriverVersion("\xEF\xBB\xBF[Version]\nDriverVer = 08/21/2026,1.4.7.12\n"), + std::string("1.4.7.12")); + + TEST("UTF-16LE INF (Windows-authored) parses"); + EXPECT_EQ(parseInfDriverVersion(utf16le("[Version]\r\nDriverVer = 08/21/2026,1.4.7.12\r\n")), + std::string("1.4.7.12")); + + TEST("infTextToNarrow leaves plain text alone"); + EXPECT_EQ(infTextToNarrow("abc"), std::string("abc")); +} + +static void testNegatives() { + TEST("missing DriverVer yields empty"); + EXPECT_EQ(parseInfDriverVersion("[Version]\nSignature = \"$WINDOWS NT$\"\n"), std::string("")); + + TEST("empty input yields empty"); + EXPECT_EQ(parseInfDriverVersion(""), std::string("")); + + TEST("DriverVer with a date but no version yields empty"); + EXPECT_EQ(parseInfDriverVersion("DriverVer = 08/21/2026\n"), std::string("")); + + TEST("DriverVer with a non-numeric version yields empty"); + EXPECT_EQ(parseInfDriverVersion("DriverVer = 08/21/2026,abc\n"), std::string("")); + + TEST("a key that merely starts with DriverVer is not matched"); + EXPECT_EQ(parseInfDriverVersion("DriverVerbose = 08/21/2026,9.9.9.9\n"), std::string("")); + + TEST("a commented-out DriverVer is ignored"); + EXPECT_EQ( + parseInfDriverVersion("; DriverVer = 08/21/2026,9.9.9.9\nDriverVer = 08/21/2026,1.2.3.4\n"), + std::string("1.2.3.4")); +} + +int main() { + std::cout << "Running driver INF tests...\n\n"; + testPlainInf(); + testEncodings(); + testNegatives(); + std::cout << "\n=== Test Results ===\n"; + std::cout << " Passed: " << g_pass << "\n"; + std::cout << " Failed: " << g_fail << "\n"; + std::cout << " STATUS: " << (g_fail == 0 ? "ALL PASSED" : "FAILED") << "\n"; + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_routes_admin.cpp b/tests/test_routes_admin.cpp index 030f13f..d13f878 100644 --- a/tests/test_routes_admin.cpp +++ b/tests/test_routes_admin.cpp @@ -251,6 +251,10 @@ int main() { EXPECT(b.contains("audio") && b["audio"].is_boolean()); EXPECT(b.contains("available")); EXPECT(b.contains("controllers") && b["controllers"].is_array()); + EXPECT(b.contains("driverVersion")); + EXPECT(b.contains("bundledVersion")); + EXPECT(b.contains("versionState") && b["versionState"].is_string()); + EXPECT(b.contains("restartPending") && b["restartPending"].is_boolean()); } } } diff --git a/tests/test_semver.cpp b/tests/test_semver.cpp index a3d1c39..9d5dd35 100644 --- a/tests/test_semver.cpp +++ b/tests/test_semver.cpp @@ -22,6 +22,7 @@ static std::string g_currentTest; } \ } while (0) +using satellite::compareDottedVersion; using satellite::compareSemver; static int sign(int v) { return v < 0 ? -1 : (v > 0 ? 1 : 0); } @@ -147,6 +148,32 @@ static void testAntisymmetry() { } } +static void testDottedVersion() { + TEST("dotted: equal four-part versions compare equal"); + EXPECT(compareDottedVersion("1.4.7.12", "1.4.7.12") == 0); + + TEST("dotted: fourth component participates, unlike semver core"); + EXPECT(sign(compareDottedVersion("1.4.7.12", "1.4.7.13")) == -1); + EXPECT(sign(compareDottedVersion("1.4.7.13", "1.4.7.12")) == 1); + EXPECT(compareSemver("1.4.7.12", "1.4.7.13") == 0); + + TEST("dotted: missing trailing components read as zero"); + EXPECT(compareDottedVersion("1.22.0", "1.22.0.0") == 0); + EXPECT(compareDottedVersion("1.22", "1.22.0.0") == 0); + + TEST("dotted: the shipped ViGEmBus pin outranks the previous upstream build"); + EXPECT(sign(compareDottedVersion("1.21.442.0", "1.22.0")) == -1); + EXPECT(sign(compareDottedVersion("1.22.0", "1.21.442.0")) == 1); + + TEST("dotted: numeric, not lexical"); + EXPECT(sign(compareDottedVersion("1.9.0", "1.10.0")) == -1); + EXPECT(sign(compareDottedVersion("1.4.7.9", "1.4.7.12")) == -1); + + TEST("dotted: non-numeric components read as zero"); + EXPECT(compareDottedVersion("1.x.0", "1.0.0") == 0); + EXPECT(compareDottedVersion("", "0.0.0.0") == 0); +} + int main() { std::cout << "Running semver tests...\n\n"; @@ -158,6 +185,7 @@ int main() { testSemverSpecChain(); testMalformedComponents(); testAntisymmetry(); + testDottedVersion(); std::cout << "\n=== Test Results ===\n"; std::cout << " Passed: " << g_pass << "\n"; diff --git a/web/dashboard.js b/web/dashboard.js index 51d7bf0..8879402 100644 --- a/web/dashboard.js +++ b/web/dashboard.js @@ -359,9 +359,22 @@ function initDashboard() { startSSE(); loadDevices(); checkBackendStatus(); + startDriverPolling(); dashRenderNetWarning(); } +let driverPollTimer = null; +const DRIVER_POLL_MS = 15000; + +function startDriverPolling() { + if (driverPollTimer) return; + driverPollTimer = setInterval(() => { + const view = document.getElementById('view-dashboard'); + if (!view || view.style.display === 'none') return; + checkBackendStatus(); + }, DRIVER_POLL_MS); +} + async function dashRenderNetWarning() { const el = document.getElementById('dash-net-notice'); if (!el) return; @@ -783,6 +796,11 @@ let lastBackends = null; function updateBackendPanel(backend, backendActive, backends) { if (backends !== undefined) lastBackends = backends; + renderBackendAlert(backend); + if (backends !== undefined) renderDriverBanner(); +} + +function renderBackendAlert(backend) { const alert = document.getElementById('backend-alert'); if (!alert) return; @@ -857,6 +875,112 @@ function populateBackendGuide(err, icon, extras) { } } +const DRIVER_BANNER_BACKENDS = ['vigem', 'hidmaestro']; +const RELEASES_URL = 'https://github.com/TinkerNorth/satellite/releases'; + +function driverDisplayName(id) { + return id === 'vigem' ? t('drivers.name.vigem') : t('drivers.name.hidmaestro'); +} + +function driverRow(b) { + const label = driverDisplayName(b.id) + (b.driverVersion ? ' ' + b.driverVersion : ''); + if (!b.available) { + if (b.errorCode === 'HELPER_MISSING') { + return { level: 'err', text: t('drivers.row.helper-missing', [label]) }; + } + if (b.errorCode === 'BUS_OPEN_FAILED') { + return { level: 'err', text: t('drivers.row.unresponsive', [label]) }; + } + return { level: 'err', text: t('drivers.row.missing', [driverDisplayName(b.id)]) }; + } + if (b.restartPending) { + return { level: 'warn', text: t('drivers.row.restart-pending', [label]) }; + } + switch (b.versionState) { + case 'outdated': return { level: 'warn', text: t('drivers.row.outdated', [label, b.bundledVersion || '']) }; + case 'newer': return { level: 'ok', text: t('drivers.row.newer', [label, b.bundledVersion || '']) }; + case 'current': return { level: 'ok', text: t('drivers.row.current', [label]) }; + default: return { level: 'ok', text: t('drivers.row.installed-unknown', [label]) }; + } +} + +function driverReleaseUrl() { + const cur = updatesState && updatesState.currentVersion; + if (cur && /^\d+\.\d+\.\d+$/.test(cur)) return RELEASES_URL + '/tag/v' + cur; + return RELEASES_URL + '/latest'; +} + +function renderDriverBanner() { + const banner = document.getElementById('driver-banner'); + if (!banner) return; + const rows = Array.isArray(lastBackends) + ? DRIVER_BANNER_BACKENDS.map(id => lastBackends.find(b => b && b.id === id)).filter(Boolean) + : []; + if (rows.length !== DRIVER_BANNER_BACKENDS.length) { + banner.style.display = 'none'; + return; + } + + const items = rows.map(b => ({ ...driverRow(b), icon: backendCopy(b.id).icon })); + const level = items.some(r => r.level === 'err') ? 'err' + : items.some(r => r.level === 'warn') ? 'warn' : 'ok'; + const restartPending = rows.some(b => b.restartPending); + + banner.classList.toggle('driver-warn', level === 'warn'); + banner.classList.toggle('driver-err', level === 'err'); + banner.style.display = 'flex'; + + const title = document.getElementById('driver-banner-title'); + if (title) { + title.textContent = level === 'err' ? t('drivers.title.missing') + : level === 'warn' ? t('drivers.title.attention') + : t('drivers.title.ok'); + } + + const list = document.getElementById('driver-list'); + if (list) { + setHTML(list, items.map(r => + `
  • ` + + `${esc(r.text)}
  • ` + ).join('')); + } + + const detail = document.getElementById('driver-banner-detail'); + const acts = document.getElementById('driver-banner-actions'); + if (!detail || !acts) return; + acts.innerHTML = ''; + if (level === 'ok') { + detail.textContent = ''; + return; + } + + const upd = updatesState; + const updState = upd ? upd.state : null; + const updateCarriesDrivers = upd && upd.info && upd.info.installMethod === 'self' && + (updState === UPDATE_STATE_AVAILABLE || updState === UPDATE_STATE_DOWNLOADING || + updState === UPDATE_STATE_VERIFYING || updState === UPDATE_STATE_DOWNLOADED); + + if (restartPending) { + detail.textContent = t('drivers.detail.restart'); + } else if (updateCarriesDrivers) { + detail.textContent = t('drivers.detail.via-update', [upd.info.version]); + } else { + detail.textContent = t('drivers.detail.rerun'); + } + + if (!updateCarriesDrivers && !restartPending) { + acts.appendChild(makeBtn('btn-start', t('drivers.btn.get-installer'), () => openExternal(driverReleaseUrl()))); + } + + const alertShown = document.getElementById('backend-alert'); + if (alertShown && alertShown.classList.contains('show')) { + acts.appendChild(makeBtn('btn-undo', t('backend.guide.toggle'), () => { + if (!backendGuideOpen) toggleBackendGuide(); + alertShown.scrollIntoView({ behavior: 'smooth', block: 'start' }); + })); + } +} + async function checkBackendStatus() { try { const r = await fetch('/api/backend/status'); diff --git a/web/index.html b/web/index.html index 1c9d6b0..d35ee09 100644 --- a/web/index.html +++ b/web/index.html @@ -71,6 +71,16 @@

    + +