mirror of
https://github.com/hakumaguro/vrcft-steam-frame.git
synced 2026-10-06 10:00:18 +02:00
VRCFaceTracking's Module Registry page crashes the app (WinUI 0xc000027b) when an installed module's module.json has no DownloadUrl/ModulePageUrl. Reproduced with UI Automation and confirmed by removing/restoring only those two fields. - common.ps1: Write-ModuleJson writes every field, no BOM - setup.ps1: repairs an incomplete or outdated module.json on existing installs - version 0.2.1; README troubleshooting row; CLAUDE.md pitfall Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
9.2 KiB
PowerShell
192 lines
9.2 KiB
PowerShell
# Shared helpers for setup.ps1 / doctor.ps1 / deploy-module.ps1. Dot-source it: . "$PSScriptRoot\common.ps1"
|
|
# Windows PowerShell 5.1 compatible (no ternaries, no ??).
|
|
|
|
$script:SteamAppId = "3329480" # VRCFaceTracking on Steam
|
|
$script:ModuleDll = "SteamFrameVRCFTModule.dll"
|
|
$script:StockDll = "SteamLinkVRCFTModule.dll"
|
|
$script:ModuleVersion = "0.2.1"
|
|
$script:RepoUrl = "https://github.com/hakumaguro/vrcft-steam-frame"
|
|
|
|
function Get-VrcftDataDir {
|
|
param([string]$Override)
|
|
if ($Override) { return $Override }
|
|
return (Join-Path $env:APPDATA "VRCFaceTracking")
|
|
}
|
|
|
|
# VRCFaceTracking install folder: Steam uninstall key, then Steam library folders, then a running process.
|
|
function Find-VrcftDir {
|
|
foreach ($k in @("HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Steam App $script:SteamAppId",
|
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App $script:SteamAppId")) {
|
|
$p = (Get-ItemProperty $k -ErrorAction SilentlyContinue).InstallLocation
|
|
if ($p -and (Test-Path (Join-Path $p "VRCFaceTracking.exe"))) { return $p.TrimEnd('\') }
|
|
}
|
|
$steam = Get-SteamDir
|
|
if ($steam) {
|
|
$libs = @($steam)
|
|
$vdf = Join-Path $steam "steamapps\libraryfolders.vdf"
|
|
if (Test-Path $vdf) {
|
|
foreach ($m in (Select-String -Path $vdf -Pattern '"path"\s+"([^"]+)"')) { $libs += ($m.Matches[0].Groups[1].Value -replace '\\\\', '\') }
|
|
}
|
|
foreach ($l in $libs) {
|
|
$c = Join-Path $l "steamapps\common\VRCFaceTracking"
|
|
if (Test-Path (Join-Path $c "VRCFaceTracking.exe")) { return $c }
|
|
}
|
|
}
|
|
$proc = Get-Process VRCFaceTracking -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($proc -and $proc.Path) { return (Split-Path $proc.Path) }
|
|
return $null
|
|
}
|
|
|
|
function Get-SteamDir {
|
|
$s = (Get-ItemProperty "HKCU:\Software\Valve\Steam" -ErrorAction SilentlyContinue).SteamPath
|
|
if ($s -and (Test-Path $s)) { return ($s -replace '/', '\') }
|
|
foreach ($c in @("${env:ProgramFiles(x86)}\Steam", "$env:ProgramFiles\Steam")) { if (Test-Path $c) { return $c } }
|
|
return $null
|
|
}
|
|
|
|
# All installed VRCFT module folders as objects: Dir, Dll, Name, Id
|
|
function Get-InstalledModules {
|
|
param([string]$DataDir)
|
|
$libs = Join-Path $DataDir "CustomLibs"
|
|
$out = @()
|
|
foreach ($d in (Get-ChildItem $libs -Directory -ErrorAction SilentlyContinue)) {
|
|
$mj = Join-Path $d.FullName "module.json"
|
|
$dll = $null; $name = $d.Name
|
|
if (Test-Path $mj) {
|
|
try { $j = Get-Content $mj -Raw | ConvertFrom-Json; $dll = $j.DllFileName; if ($j.ModuleName) { $name = $j.ModuleName } } catch {}
|
|
}
|
|
if (-not $dll) { $f = Get-ChildItem $d.FullName -Filter *.dll -ErrorAction SilentlyContinue | Select-Object -First 1; if ($f) { $dll = $f.Name } }
|
|
$out += [pscustomobject]@{ Dir = $d.FullName; Dll = $dll; Name = $name; Id = $d.Name }
|
|
}
|
|
return $out
|
|
}
|
|
|
|
function Get-OurModule { param([string]$DataDir) Get-InstalledModules $DataDir | Where-Object { $_.Dll -eq $script:ModuleDll } | Select-Object -First 1 }
|
|
function Get-StockSteamLinkModules { param([string]$DataDir) Get-InstalledModules $DataDir | Where-Object { $_.Dll -eq $script:StockDll } }
|
|
|
|
function Get-SteamVrSettingsPath {
|
|
$steam = Get-SteamDir
|
|
if (-not $steam) { return $null }
|
|
return (Join-Path $steam "config\steamvr.vrsettings")
|
|
}
|
|
|
|
# Read the two Steam Link driver values that matter. Returns @{Port=..; ShareEye=..} (nulls when unset)
|
|
function Get-VrlinkSettings {
|
|
param([string]$Path)
|
|
$r = @{ Port = $null; ShareEye = $null; Ok = $false }
|
|
if (-not $Path -or -not (Test-Path $Path)) { return $r }
|
|
try {
|
|
$j = Get-Content $Path -Raw | ConvertFrom-Json
|
|
if ($j.driver_vrlink) { $r.Port = $j.driver_vrlink.OSCOutPort; $r.ShareEye = $j.driver_vrlink.shareEyeTrackingData }
|
|
} catch {}
|
|
$r.Ok = ("$($r.Port)" -eq "9015" -and $r.ShareEye -eq $true)
|
|
return $r
|
|
}
|
|
|
|
function Test-SteamVrRunning { return [bool](Get-Process vrserver, vrmonitor, vrcompositor -ErrorAction SilentlyContinue) }
|
|
|
|
# VRCFT's own OSC output port (must be 9000 for VRChat). Stored as a string in LocalSettings.json.
|
|
function Get-VrcftLocalSettingsPath { param([string]$DataDir) Join-Path $DataDir "VRCFaceTracking\ApplicationData\LocalSettings.json" }
|
|
function Get-VrcftOscOutPort {
|
|
param([string]$DataDir)
|
|
$p = Get-VrcftLocalSettingsPath $DataDir
|
|
if (-not (Test-Path $p)) { return $null }
|
|
$m = Select-String -Path $p -Pattern '"OSCOutPort"\s*:\s*"?(\d+)"?' | Select-Object -First 1
|
|
if ($m) { return $m.Matches[0].Groups[1].Value }
|
|
return $null
|
|
}
|
|
|
|
# Inbound firewall state for the module process: "ok", "blocked", "missing" (+ which network categories are uncovered)
|
|
function Test-ModuleFirewall {
|
|
param([string]$VrcftDir)
|
|
$exe = Join-Path $VrcftDir "VRCFaceTracking.ModuleProcess.exe"
|
|
$rules = @(Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue | Where-Object { $_.Program -ieq $exe } |
|
|
Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { "$($_.Enabled)" -eq "True" -and "$($_.Direction)" -eq "Inbound" })
|
|
if ($rules | Where-Object { "$($_.Action)" -eq "Block" }) { return @{ State = "blocked"; Exe = $exe } }
|
|
$allow = @($rules | Where-Object { "$($_.Action)" -eq "Allow" })
|
|
$cats = @(Get-NetConnectionProfile -ErrorAction SilentlyContinue | ForEach-Object { ("$($_.NetworkCategory)" -replace 'DomainAuthenticated', 'Domain') } | Select-Object -Unique)
|
|
$missing = @()
|
|
foreach ($c in $cats) {
|
|
$covered = $allow | Where-Object { "$($_.Profile)" -match "Any" -or "$($_.Profile)" -match $c }
|
|
if (-not $covered) { $missing += $c }
|
|
}
|
|
if ($missing.Count -gt 0) { return @{ State = "missing"; Exe = $exe; Missing = ($missing -join ", ") } }
|
|
return @{ State = "ok"; Exe = $exe }
|
|
}
|
|
|
|
function Read-ModuleStatus {
|
|
$p = Join-Path ([IO.Path]::GetTempPath()) "steamframe-status.json"
|
|
if (-not (Test-Path $p)) { return $null }
|
|
try {
|
|
$s = Get-Content $p -Raw | ConvertFrom-Json
|
|
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
$s | Add-Member -NotePropertyName AgeMs -NotePropertyValue ($now - [long]$s.updated) -Force
|
|
return $s
|
|
} catch { return $null }
|
|
}
|
|
|
|
function Say {
|
|
param([string]$Text)
|
|
if ($script:NoSpeech) { return }
|
|
try {
|
|
Add-Type -AssemblyName System.Speech
|
|
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
$s.Speak($Text); $s.Dispose()
|
|
} catch {}
|
|
}
|
|
|
|
function Start-VrcftViaSteam {
|
|
# Starting VRCFaceTracking.exe directly never spawns the module process; Steam must launch it.
|
|
Start-Process "steam://rungameid/$script:SteamAppId"
|
|
}
|
|
|
|
function Stop-Vrcft {
|
|
$p = Get-Process VRCFaceTracking* -ErrorAction SilentlyContinue
|
|
if ($p) { $p | Stop-Process -Force; Start-Sleep 2; return $true }
|
|
return $false
|
|
}
|
|
|
|
# A .NET 10 SDK: PATH first, then the per-user install location. Returns the dotnet.exe path or $null.
|
|
function Find-DotnetSdk10 {
|
|
foreach ($c in @("dotnet", "$env:USERPROFILE\.dotnet\dotnet.exe")) {
|
|
$cmd = Get-Command $c -ErrorAction SilentlyContinue
|
|
if ($cmd -and (& $cmd.Source --list-sdks 2>$null | Select-String "^10\.")) { return $cmd.Source }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# Where the headset should send. Keeps an existing service target only while it is still one of this PC's IPv4
|
|
# addresses (DHCP can change it); otherwise uses the address the headset's SSH session came from.
|
|
function Select-HeadsetTarget {
|
|
param([string]$Override, [string]$Existing, [string]$Client, [string[]]$LocalIps)
|
|
if (-not $LocalIps) { $LocalIps = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress }) }
|
|
if ($Override) { return @{ Target = $Override; Reason = "as requested" } }
|
|
$old = ""
|
|
if ($Existing) { $old = ($Existing -split ':')[0] }
|
|
if ($old -and ($LocalIps -contains $old)) { return @{ Target = $old; Reason = "keeping the existing target $old (still this PC's address)" } }
|
|
if ($old) { return @{ Target = $Client; Reason = "the old target $old is no longer this PC's address; switching to $Client" } }
|
|
return @{ Target = $Client; Reason = "the headset reaches this PC at $Client" }
|
|
}
|
|
|
|
# module.json for VRCFT. Every field the registry page shows must be present: without DownloadUrl/ModulePageUrl,
|
|
# opening "Module Registry" crashes VRCFaceTracking (WinUI E_INVALIDARG, reproduced 2026-09-27). Written without a BOM.
|
|
function Write-ModuleJson {
|
|
param([string]$Dir, [string]$Id)
|
|
$j = [ordered]@{
|
|
InstallationState = 0; ModuleId = $Id; LastUpdated = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
Version = $script:ModuleVersion; IsLocal = $true; Downloads = 0; Ratings = 0; Rating = 0
|
|
AuthorName = "vrcft-steam-frame"; ModuleName = "Steam Frame Eye Tracking"
|
|
ModuleDescription = "Per-eye gaze and eyelids for Steam Frame (frameeyeosc), Steam Link OSC fallback"
|
|
UsageInstructions = "See the README: $script:RepoUrl"
|
|
DownloadUrl = "$script:RepoUrl/releases/latest"; ModulePageUrl = $script:RepoUrl
|
|
DllFileName = $script:ModuleDll; FileHash = $null
|
|
}
|
|
[IO.File]::WriteAllText((Join-Path $Dir "module.json"), ($j | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
|
|
}
|
|
|
|
function Write-Step { param([string]$State, [string]$Text)
|
|
$color = @{ ok = "Green"; done = "Green"; warn = "Yellow"; fail = "Red"; would = "Cyan"; skip = "DarkGray"; info = "Gray" }[$State]
|
|
if (-not $color) { $color = "Gray" }
|
|
Write-Host ("[{0,-5}] {1}" -f $State, $Text) -ForegroundColor $color
|
|
}
|