diff --git a/.gitignore b/.gitignore index 2fa9152..d627f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# RIOJoy deployment: built zips and bundled third-party installers (not source). +/dist/ +/deploy/vendor/ + # User-specific files *.rsuser *.suo diff --git a/deploy/README-DEPLOY.txt b/deploy/README-DEPLOY.txt new file mode 100644 index 0000000..8a02482 --- /dev/null +++ b/deploy/README-DEPLOY.txt @@ -0,0 +1,54 @@ +RIOJoy — cockpit deployment package +==================================== + +This package deploys RIOJoy to a single directory (e.g. C:\games\RIOJOY) and runs +all local installation steps from postinstall.bat. It uses the signed ViGEmBus +virtual-controller driver, so there is NO test signing, NO Secure Boot change, and +NO reboot. + +Contents +-------- + postinstall.bat Entry point (at the root). Elevates, then runs + riojoy\install-rio.ps1. + riojoy\ The payload folder: + app\ The RIOJoy tray application (self-contained .NET 8 — no runtime + install required on the target machine). + vendor\ The signed ViGEmBus installer (Nefarius, WHQL/attestation-signed). + install-rio.ps1 The actual install steps (idempotent). + VERSION.txt Build stamp (date + git short SHA). + +What postinstall.bat does +------------------------- + 1. Elevates to administrator (UAC) if not already elevated. + 2. Installs ViGEmBus silently if its driver service is not already present. + 3. Registers RIOJoy to start at logon (HKLM ...\Run, machine-wide). + 4. Launches RIOJoy. + +Deploying with TeslaConsole +--------------------------- + 1. Extract this zip so that postinstall.bat and the riojoy\ folder sit together + (e.g. under C:\games\, giving C:\games\postinstall.bat and C:\games\riojoy\). + 2. Run postinstall.bat ELEVATED (TeslaConsole should run it as administrator). + It is idempotent — re-running it is safe (e.g. for updates). The app and + ViGEmBus install from the riojoy\ folder; install-rio.ps1 locates itself, so + the riojoy\ folder can live wherever it is extracted. + +After install +------------- + * RIOJoy runs in the system tray. Right-click it to pick/edit profiles, set the + auto-switch executables, and toggle output to the PC. + * The RIO's joystick output appears to games as a standard "Xbox 360 Controller" + (verify in joy.cpl). Note the XInput layout limits joystick BUTTONS to 11 + (A, B, X, Y, LB, RB, Back, Start, L3, R3, Guide); bind everything else to the + keyboard. The hat maps to the D-pad; axes map to the sticks and triggers. + * Per-machine config lives in %APPDATA%\RIOJoy\config.json for the running user. + +Building this package (on a dev machine) +----------------------------------------- + Requires the .NET 8 SDK and the signed ViGEmBus installer. + + powershell -ExecutionPolicy Bypass -File deploy\build-package.ps1 ` + -VigemInstaller + + (Or drop ViGEmBus_*.exe into deploy\vendor\ and omit -VigemInstaller.) + Output: dist\RIOJoy-.zip diff --git a/deploy/build-package.ps1 b/deploy/build-package.ps1 new file mode 100644 index 0000000..9cad919 --- /dev/null +++ b/deploy/build-package.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS + Build the RIOJoy cockpit deployment zip: publish the tray app self-contained, + bundle the signed ViGEmBus installer + the post-install scripts, and zip it for + TeslaConsole. The zip extracts directly into a single directory (C:\games\RIOJOY). + +.PARAMETER VigemInstaller + Path to the signed ViGEmBus installer to bundle. If omitted, looks in + deploy\vendor\ViGEmBus*.exe. +.PARAMETER OutDir + Where to write the zip (default: dist, relative to the repo root). +.PARAMETER Configuration + Build configuration (default: Release). +#> +param( + [string]$VigemInstaller, + [string]$OutDir = 'dist', + [string]$Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent # deploy\ -> repo root +$staging = Join-Path ([IO.Path]::GetTempPath()) "riojoy-pkg-$([Guid]::NewGuid().ToString('N'))" + +Write-Host '== RIOJoy deployment package ==' -ForegroundColor Cyan + +# --- version stamp -------------------------------------------------------- +$sha = (& git -C $repo rev-parse --short HEAD 2>$null) +if (-not $sha) { $sha = 'nogit' } +$version = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd'), $sha + +# --- locate the ViGEmBus installer ---------------------------------------- +if (-not $VigemInstaller) { + $VigemInstaller = Get-ChildItem -Path (Join-Path $PSScriptRoot 'vendor') -Filter 'ViGEmBus*.exe' ` + -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $VigemInstaller -or -not (Test-Path $VigemInstaller)) { + throw "ViGEmBus installer not found. Pass -VigemInstaller , or drop ViGEmBus_*.exe into deploy\vendor\. Get it from https://github.com/nefarius/ViGEmBus/releases." +} +$sig = Get-AuthenticodeSignature $VigemInstaller +if ($sig.Status -ne 'Valid') { throw "ViGEmBus installer is not validly signed ($($sig.Status)): $VigemInstaller" } +Write-Host "ViGEmBus installer: $(Split-Path $VigemInstaller -Leaf) (signature $($sig.Status))" + +try { + # The payload lives in a 'riojoy' folder; postinstall.bat sits beside it at the + # zip root (TeslaConsole runs it; it calls riojoy\install-rio.ps1). + $pkgDir = Join-Path $staging 'riojoy' + New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null + + # 1. Publish the tray app into riojoy\app (self-contained win-x64 — no .NET on target). + $appOut = Join-Path $pkgDir 'app' + Write-Host "Publishing RioJoy.Tray ($Configuration, self-contained win-x64)..." + & dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') ` + -c $Configuration -r win-x64 --self-contained true ` + -p:PublishSingleFile=false -p:DebugType=none ` + -o $appOut | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' } + + # 2. Bundle the signed ViGEmBus installer into riojoy\vendor. + $vendorOut = Join-Path $pkgDir 'vendor' + New-Item -ItemType Directory -Force -Path $vendorOut | Out-Null + Copy-Item $VigemInstaller $vendorOut + + # 3. Install script + readme + version stamp inside riojoy\; postinstall.bat at root. + Copy-Item (Join-Path $PSScriptRoot 'install-rio.ps1') $pkgDir + Copy-Item (Join-Path $PSScriptRoot 'README-DEPLOY.txt') $pkgDir + Set-Content -Path (Join-Path $pkgDir 'VERSION.txt') -Value "RIOJoy $version" -Encoding utf8 + Copy-Item (Join-Path $PSScriptRoot 'postinstall.bat') $staging + + # 4. Zip the package contents (so it extracts straight into C:\games\RIOJOY). + $outDirFull = if ([IO.Path]::IsPathRooted($OutDir)) { $OutDir } else { Join-Path $repo $OutDir } + New-Item -ItemType Directory -Force -Path $outDirFull | Out-Null + $zip = Join-Path $outDirFull "RIOJoy-$version.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Write-Host "Zipping -> $zip" + # Build entries by hand with forward-slash names: both Compress-Archive and + # ZipFile.CreateFromDirectory write BACKSLASH separators under Windows PowerShell + # (.NET Framework), which some extractors mishandle. Entries are relative to the + # staging root, so the zip extracts straight into the target (C:\games\RIOJOY\...). + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $base = (Resolve-Path $staging).Path.TrimEnd('\') + '\' + $fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::CreateNew) + try { + $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in Get-ChildItem -Path $staging -Recurse -File) { + $entryName = $file.FullName.Substring($base.Length) -replace '\\', '/' + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $file.FullName, $entryName, + [System.IO.Compression.CompressionLevel]::Optimal) | Out-Null + } + } finally { $archive.Dispose() } + } finally { $fs.Dispose() } + + $size = '{0:N1} MB' -f ((Get-Item $zip).Length / 1MB) + Write-Host '' + Write-Host "Package built: $zip ($size)" -ForegroundColor Green + Write-Host 'Deploy: extract (postinstall.bat + riojoy\), then run postinstall.bat (elevated).' +} +finally { + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } +} diff --git a/deploy/install-rio.ps1 b/deploy/install-rio.ps1 new file mode 100644 index 0000000..7cb29a4 --- /dev/null +++ b/deploy/install-rio.ps1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + RIOJoy cockpit post-install. Installs the signed ViGEmBus virtual-controller + driver (if absent) and sets RIOJoy to start at logon. Must run elevated + (postinstall.bat elevates for you). Idempotent — safe to re-run. + +.DESCRIPTION + Run from inside the deployed package directory (e.g. C:\games\RIOJOY). Paths + are resolved relative to this script, so the layout is: + + \app\RioJoy.Tray.exe the published tray app (self-contained) + \vendor\ViGEmBus_*.exe the signed ViGEmBus installer + \install-rio.ps1 (this file) + + ViGEmBus is WHQL/attestation-signed: it installs with NO test signing, NO + Secure Boot change, and NO reboot. The RIO appears to games as a standard + Xbox 360 controller. + +.PARAMETER NoLaunch + Do not start the app after install (it still starts at the next logon). +#> +param([switch]$NoLaunch) + +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +$appExe = Join-Path $root 'app\RioJoy.Tray.exe' +$vendorDir = Join-Path $root 'vendor' +$runValueName = 'RIOJoy' + +# --- guards --------------------------------------------------------------- +$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'install-rio.ps1 must run elevated. Run postinstall.bat (it elevates).' +} +if (-not (Test-Path $appExe)) { throw "App not found: $appExe (is the package complete?)." } + +Write-Host '== RIOJoy post-install ==' -ForegroundColor Cyan + +# 1. Install ViGEmBus if its driver service is not already present. +$svc = Get-Service -Name ViGEmBus -ErrorAction SilentlyContinue +if ($svc) { + Write-Host "ViGEmBus already installed (service is $($svc.Status))." +} else { + $installer = Get-ChildItem -Path $vendorDir -Filter 'ViGEmBus*.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($installer) { + Write-Host "Installing ViGEmBus ($($installer.Name))..." + $p = Start-Process -FilePath $installer.FullName ` + -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru + if ($p.ExitCode -notin 0, 3010) { + throw "ViGEmBus install failed (exit code $($p.ExitCode))." + } + Write-Host 'ViGEmBus installed.' + } else { + Write-Warning "ViGEmBus installer not found in $vendorDir — joystick output will be" + Write-Warning "unavailable until ViGEmBus is installed (keyboard/mouse still work)." + } +} + +# 2. Start RIOJoy at logon (machine-wide Run key -> runs in each user's session). +Write-Host 'Registering RIOJoy to start at logon...' +$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' +New-ItemProperty -Path $runKey -Name $runValueName -Value "`"$appExe`"" -PropertyType String -Force | Out-Null + +# --- summary -------------------------------------------------------------- +Write-Host '' +Write-Host "RIOJoy installed to $root" -ForegroundColor Green +Write-Host 'RIOJoy starts automatically at logon.' +if (-not $NoLaunch) { + Write-Host 'Launching RIOJoy...' + # Launch via explorer so the tray app runs de-elevated in the user session. + Start-Process -FilePath (Join-Path $env:WINDIR 'explorer.exe') -ArgumentList $appExe +} +exit 0 diff --git a/deploy/postinstall.bat b/deploy/postinstall.bat new file mode 100644 index 0000000..ae4d217 --- /dev/null +++ b/deploy/postinstall.bat @@ -0,0 +1,29 @@ +@echo off +rem =========================================================================== +rem RIOJoy cockpit post-install entry point (run by TeslaConsole). +rem Sits beside the 'riojoy' payload folder; elevates, then runs +rem riojoy\install-rio.ps1. +rem =========================================================================== +setlocal +title RIOJoy post-install + +rem --- elevate if we are not already administrator -------------------------- +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo Requesting administrator privileges... + powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b 0 +) + +cd /d "%~dp0" + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0riojoy\install-rio.ps1" +set RC=%errorlevel% + +echo. +if %RC% neq 0 ( + echo postinstall FAILED with exit code %RC%. +) else ( + echo postinstall completed. +) +exit /b %RC% diff --git a/src/RioJoy.Tray/Program.cs b/src/RioJoy.Tray/Program.cs index ba57da7..9f77fc3 100644 --- a/src/RioJoy.Tray/Program.cs +++ b/src/RioJoy.Tray/Program.cs @@ -2,6 +2,12 @@ namespace RioJoy.Tray; internal static class Program { + // Per-session single-instance guard. Two instances would both grab the RIO COM + // port, so a second launch (e.g. machine-wide + per-user autostart both firing) + // must exit immediately. A named mutex is released by the OS if the owner dies, + // so a crash never leaves a stale lock. + private const string SingleInstanceMutex = "RIOJoy.Tray.SingleInstance"; + /// /// Entry point. RIOJoy runs as a background tray application with no main /// window: an ApplicationContext owns the NotifyIcon and the runtime, so the @@ -10,6 +16,10 @@ internal static class Program [STAThread] private static void Main() { + using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew); + if (!createdNew) + return; // another RIOJoy is already running in this session + ApplicationConfiguration.Initialize(); Application.Run(new TrayApplicationContext()); }