CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<#
|
||||
One-off migration: give every install folder the 4-character id suffix again.
|
||||
|
||||
before: <library>\installs\Public Scripting\
|
||||
after: <library>\installs\Public Scripting-9ce8\ (suffix = first 4 characters of the install's Id in config.json)
|
||||
|
||||
Run it after 2026-10-05-flatten-install-layout.ps1 (which produced names without a suffix).
|
||||
With the suffix, two installs may share a name without their folders colliding.
|
||||
|
||||
Dry run by default. Run with pwsh.
|
||||
pwsh -File 2026-10-05-add-folder-id-suffix.ps1 # show the plan
|
||||
pwsh -File 2026-10-05-add-folder-id-suffix.ps1 -Apply
|
||||
|
||||
Close CVRManager and every game from the library first. config.json is backed up next to itself,
|
||||
and progress is saved after every install, so a folder held open by Explorer only blocks that install.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply,
|
||||
[switch]$SkipAppCheck # only for testing against a throwaway copy
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-SafeFolderName([string]$name) {
|
||||
$bad = [IO.Path]::GetInvalidFileNameChars()
|
||||
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
||||
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
||||
$clean = $clean.Trim().TrimEnd('.')
|
||||
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
||||
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
||||
}
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$installs = Join-Path $lib 'installs'
|
||||
|
||||
$running = @(if (-not $SkipAppCheck) { Get-Process CVRManager -ErrorAction SilentlyContinue }) + @(Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if ($Apply -and $running.Count -gt 0) { throw "Close CVRManager and any game started from the library first (running: $(($running | ForEach-Object { $_.ProcessName + ' ' + $_.Id }) -join ', '))." }
|
||||
|
||||
$all = @($cfg.Installs)
|
||||
$plans = @()
|
||||
foreach ($i in $all) {
|
||||
$stem = "$(Get-SafeFolderName $i.Name)-$($i.Id.Substring(0, 4))"
|
||||
$want = $stem; $n = 2
|
||||
while ($true) {
|
||||
$clash = $all | Where-Object { $_ -ne $i -and $_.Folder -ieq $want }
|
||||
$existsOther = ($want -ine $i.Folder) -and (Test-Path (Join-Path $installs $want))
|
||||
if (-not $clash -and -not $existsOther) { break }
|
||||
$want = "$stem-$n"; $n++
|
||||
}
|
||||
if ($want -cne $i.Folder) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $want } }
|
||||
}
|
||||
|
||||
"Library: $lib"
|
||||
if ($plans.Count -eq 0) { 'Nothing to do: every install folder already has its suffix.'; return }
|
||||
"Planned renames:"
|
||||
foreach ($p in $plans) { " - $($p.Install.Name): '$($p.From)' -> '$($p.To)'" + $(if (-not (Test-Path (Join-Path $installs $p.From))) { ' (folder missing on disk: config entry only)' } else { '' }) }
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to make these changes.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
|
||||
function Save-Config { [IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false))) }
|
||||
$failed = @()
|
||||
foreach ($p in $plans) {
|
||||
$oldDir = Join-Path $installs $p.From
|
||||
$newDir = Join-Path $installs $p.To
|
||||
try {
|
||||
if (Test-Path $oldDir) {
|
||||
if ($oldDir -ieq $newDir) { $tmp = "$oldDir.renaming"; [IO.Directory]::Move($oldDir, $tmp); [IO.Directory]::Move($tmp, $newDir) }
|
||||
else { [IO.Directory]::Move($oldDir, $newDir) }
|
||||
}
|
||||
$p.Install.Folder = $p.To
|
||||
Save-Config
|
||||
" done: $($p.Install.Name) -> $newDir"
|
||||
}
|
||||
catch [UnauthorizedAccessException], [IO.IOException] {
|
||||
$failed += $p.Install.Name
|
||||
Write-Warning "$($p.Install.Name): $($_.Exception.Message)"
|
||||
Write-Warning 'A folder of this install is probably open in Explorer or another program. Close it and run the script again.'
|
||||
}
|
||||
}
|
||||
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was renamed and saved." } else { 'Config updated. Start CVRManager again.' }
|
||||
@@ -0,0 +1,140 @@
|
||||
<#
|
||||
One-off migration: old install layout -> current layout.
|
||||
|
||||
old: <library>\installs\<Name>-<4 hex>\game\ChilloutVR.exe
|
||||
new: <library>\installs\<Name>\ChilloutVR.exe
|
||||
|
||||
Also registers install folders that exist on disk but are missing from config.json
|
||||
(name taken from the folder, branch from the game's appmanifest BetaKey).
|
||||
|
||||
Dry run by default. Nothing is changed unless -Apply is given. Run with pwsh.
|
||||
pwsh -File 2026-10-05-flatten-install-layout.ps1 # show the plan
|
||||
pwsh -File 2026-10-05-flatten-install-layout.ps1 -Apply # do it
|
||||
|
||||
Close CVRManager and every game from the library first. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply,
|
||||
[switch]$SkipAppCheck # only for testing against a throwaway copy; skips the 'CVRManager is running' check
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-SafeFolderName([string]$name) {
|
||||
$bad = [IO.Path]::GetInvalidFileNameChars()
|
||||
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
||||
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
||||
$clean = $clean.Trim().TrimEnd('.')
|
||||
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
||||
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
||||
}
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$installs = Join-Path $lib 'installs'
|
||||
if (-not (Test-Path $installs)) { 'No installs folder; nothing to do.'; return }
|
||||
|
||||
$running = @(if (-not $SkipAppCheck) { Get-Process CVRManager -ErrorAction SilentlyContinue }) + @(Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if ($Apply -and $running.Count -gt 0) { throw "Close CVRManager and any game started from the library first (running: $(($running | ForEach-Object { $_.ProcessName + ' ' + $_.Id }) -join ', '))." }
|
||||
|
||||
$list = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($i in @($cfg.Installs)) { $list.Add($i) }
|
||||
$newEntries = @()
|
||||
|
||||
# orphan folders: on disk, not in config
|
||||
$known = @($list | ForEach-Object { $_.Folder.ToLowerInvariant() })
|
||||
foreach ($dir in Get-ChildItem $installs -Directory) {
|
||||
if ($known -contains $dir.Name.ToLowerInvariant()) { continue }
|
||||
$hasExe = (Test-Path (Join-Path $dir.FullName 'ChilloutVR.exe')) -or (Test-Path (Join-Path $dir.FullName 'game\ChilloutVR.exe'))
|
||||
if (-not $hasExe) { continue }
|
||||
$m = [regex]::Match($dir.Name, '^(.*)-([0-9a-f]{4})$')
|
||||
$name = if ($m.Success) { $m.Groups[1].Value.Replace('-', ' ').Trim() } else { $dir.Name }
|
||||
$branch = 'public'
|
||||
foreach ($acf in @((Join-Path $dir.FullName 'game\steamapps\appmanifest_661130.acf'), (Join-Path $dir.FullName 'steamapps\appmanifest_661130.acf'))) {
|
||||
if (Test-Path $acf) {
|
||||
$b = [regex]::Match((Get-Content $acf -Raw), '"UserConfig"\s*\{[^}]*?"BetaKey"\s*"([^"]*)"')
|
||||
if ($b.Success -and $b.Groups[1].Value) { $branch = $b.Groups[1].Value; break }
|
||||
}
|
||||
}
|
||||
$defaultHooks = @($cfg.Hooks | Where-Object { $_.DefaultOn } | ForEach-Object { $_.Id })
|
||||
$entry = [pscustomobject]@{
|
||||
Id = ([guid]::NewGuid().ToString('N')).Substring(0, 8); Name = $name; Folder = $dir.Name; Branch = $branch
|
||||
BranchPassword = ''; LaunchArgs = ''; LaunchMode = 'desktop'; Mods = @(); HookIds = $defaultHooks; Deployed = [pscustomobject]@{}
|
||||
}
|
||||
$list.Add($entry); $newEntries += $entry
|
||||
}
|
||||
|
||||
function Test-Taken([string]$candidate, $self) {
|
||||
foreach ($o in $list) { if ($o -ne $self -and $o.Folder -ieq $candidate) { return $true } }
|
||||
if ($candidate -ieq $self.Folder) { return $false }
|
||||
return (Test-Path (Join-Path $installs $candidate))
|
||||
}
|
||||
|
||||
$plans = @()
|
||||
foreach ($i in $list) {
|
||||
$legacy = Join-Path (Join-Path $installs $i.Folder) 'game'
|
||||
$needsFlatten = (Test-Path $legacy) -and ((Test-Path (Join-Path $legacy 'ChilloutVR.exe')) -or -not (Test-Path (Join-Path (Join-Path $installs $i.Folder) 'ChilloutVR.exe')))
|
||||
$base = Get-SafeFolderName $i.Name
|
||||
$candidate = $base; $n = 2
|
||||
while (Test-Taken $candidate $i) { $candidate = "$base ($n)"; $n++ }
|
||||
$needsRename = $candidate -cne $i.Folder
|
||||
if ($needsFlatten -or $needsRename) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $candidate; Flatten = $needsFlatten; Rename = $needsRename; Registers = ($newEntries -contains $i) } }
|
||||
}
|
||||
|
||||
"Library: $lib"
|
||||
if ($newEntries.Count -gt 0) { "Install folders on disk that are missing from the config ($($newEntries.Count)):"; $newEntries | ForEach-Object { " + register '$($_.Name)' (folder '$($_.Folder)', branch '$($_.Branch)')" } }
|
||||
if ($plans.Count -eq 0 -and $newEntries.Count -eq 0) { 'Nothing to do: every install already uses the current layout.'; return }
|
||||
"Planned moves:"
|
||||
foreach ($p in $plans) {
|
||||
$what = @(); if ($p.Rename) { $what += "rename folder '$($p.From)' -> '$($p.To)'" }; if ($p.Flatten) { $what += "move the contents of 'game' up one level" }
|
||||
" - $($p.Install.Name): $($what -join '; ')"
|
||||
}
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to make these changes.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
|
||||
function Save-Config([object[]]$installsToWrite) {
|
||||
$cfg.Installs = @($installsToWrite)
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
# progress is written after every install, so a failure part-way never leaves the config out of step with the disk
|
||||
$registered = @($list | Where-Object { $newEntries -notcontains $_ }) # installs that were already in the config stay there
|
||||
$failed = @()
|
||||
foreach ($p in $plans) {
|
||||
$oldDir = Join-Path $installs $p.From
|
||||
$newDir = Join-Path $installs $p.To
|
||||
try {
|
||||
if ($p.Rename) {
|
||||
if ($oldDir -ieq $newDir) { $tmp = "$oldDir.renaming"; [IO.Directory]::Move($oldDir, $tmp); [IO.Directory]::Move($tmp, $newDir) }
|
||||
else { [IO.Directory]::Move($oldDir, $newDir) }
|
||||
$p.Install.Folder = $p.To
|
||||
}
|
||||
$legacy = Join-Path $newDir 'game'
|
||||
if ($p.Flatten -and (Test-Path $legacy)) {
|
||||
foreach ($entry in Get-ChildItem $legacy -Force) {
|
||||
$dest = Join-Path $newDir $entry.Name
|
||||
if (Test-Path $dest) { Write-Warning "Skipped '$($entry.Name)' for $($p.Install.Name): already exists at the destination"; continue }
|
||||
if ($entry.PSIsContainer) { [IO.Directory]::Move($entry.FullName, $dest) } else { [IO.File]::Move($entry.FullName, $dest) }
|
||||
}
|
||||
if (-not (Get-ChildItem $legacy -Force)) { Remove-Item $legacy }
|
||||
}
|
||||
if ($newEntries -contains $p.Install) { $registered += $p.Install }
|
||||
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
|
||||
" done: $($p.Install.Name) -> $newDir"
|
||||
}
|
||||
catch [UnauthorizedAccessException], [IO.IOException] {
|
||||
$failed += $p.Install.Name
|
||||
Write-Warning "$($p.Install.Name): $($_.Exception.Message)"
|
||||
Write-Warning "A folder of this install is probably open in Explorer or another program. Close it and run the script again; it continues where it stopped."
|
||||
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was migrated and saved." }
|
||||
else { 'Config updated. Start CVRManager again.' }
|
||||
@@ -0,0 +1,99 @@
|
||||
<#
|
||||
One-off migration: import the beta access codes listed in "<library>\branch codes.txt" (one code per line) into CVRManager.
|
||||
|
||||
Each code is resolved with Steam (anonymous CheckAppBetaPassword, the same call the app's "Enter beta code" box makes), so a
|
||||
code that unlocks several branches is stored once per branch. For every branch it unlocks:
|
||||
- config.json -> Unlocked gets { Name, Code } (an existing entry for that branch, e.g. a name-only one, gets the code filled in)
|
||||
- installs on that branch get BranchPassword set, so Update works without opening the dropdown
|
||||
Codes are never printed; they are shown as a length only.
|
||||
The codes are checked as your signed-in Steam account, so sign in first: CVRManager > Settings > "Sign in (beta codes)".
|
||||
The saved login token is read with the same Windows user that signed in, so run this as that user.
|
||||
|
||||
Needs a built app for the resolver: -BinDir (default bin\v26 next to the project) must contain CVRManager.dll and SteamKit2.dll.
|
||||
Run it OUTSIDE Claude's sandbox (a normal terminal), otherwise it edits a private copy of %APPDATA%.
|
||||
|
||||
Dry run by default (resolves codes, writes nothing).
|
||||
pwsh -File 2026-10-05-import-branch-codes.ps1
|
||||
pwsh -File 2026-10-05-import-branch-codes.ps1 -Apply
|
||||
Close CVRManager first with -Apply. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[string]$CodesFile,
|
||||
[string]$BinDir = (Join-Path $PSScriptRoot '..\..\bin\v26'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
if (-not $CodesFile) { $CodesFile = Join-Path $lib 'branch codes.txt' }
|
||||
if (-not (Test-Path $CodesFile)) { throw "No codes file at $CodesFile" }
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
|
||||
$BinDir = (Resolve-Path $BinDir).Path
|
||||
[void][Reflection.Assembly]::LoadFrom((Join-Path $BinDir 'SteamKit2.dll'))
|
||||
[void][Reflection.Assembly]::LoadFrom((Join-Path $BinDir 'CVRManager.dll'))
|
||||
$resolver = [CVRManager.Services.BranchCodeService]
|
||||
$account = $cfg.SteamUser
|
||||
$token = [CVRManager.Services.TokenVault]::Unprotect($cfg.SteamToken)
|
||||
if (-not $token) { throw 'Not signed in to Steam in the app. Open CVRManager > Settings > "Sign in (beta codes)", then run this again.' }
|
||||
"Checking codes as Steam account '$account'."
|
||||
|
||||
$codes = @(Get-Content $CodesFile | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique)
|
||||
"Codes in file: $($codes.Count)"
|
||||
|
||||
$known = @($cfg.Unlocked | Where-Object { $_.Code } | ForEach-Object { $_.Code } | Select-Object -Unique |
|
||||
Where-Object { $c = $_; -not @($cfg.Unlocked | Where-Object { $_.Code -ceq $c -and -not $_.Key }).Count })
|
||||
$resolved = @()
|
||||
$n = 0
|
||||
foreach ($code in $codes) {
|
||||
$n++
|
||||
if ($known -ccontains $code) { " code $n (len $($code.Length)): already stored with its key, skipped"; continue }
|
||||
for ($try = 1; $try -le 6; $try++) {
|
||||
try {
|
||||
$keys = $resolver::ResolveAsync($code, $account, $token, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
|
||||
$names = @($keys.Keys)
|
||||
" code $n (len $($code.Length)): unlocks $($names.Count) branch(es): $($names -join ', ')"
|
||||
foreach ($name in $names) { $resolved += [pscustomobject]@{ Name = $name; Code = $code; Key = $resolver::ProtectKey($keys[$name]) } }
|
||||
break
|
||||
}
|
||||
catch {
|
||||
$msg = $_.Exception.InnerException.Message ?? $_.Exception.Message
|
||||
if ($msg -match 'RateLimitExceeded' -and $try -lt 6) { " code ${n}: Steam rate limit, waiting 60 s (try $try of 5)"; Start-Sleep 60; continue }
|
||||
" code $n (len $($code.Length)): NOT resolved - $msg"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($resolved.Count -eq 0) { 'Nothing resolved; nothing to do.'; return }
|
||||
|
||||
$unlocked = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($u in @($cfg.Unlocked)) { $unlocked.Add($u) }
|
||||
$added = 0; $filled = 0; $same = 0; $installsSet = 0
|
||||
foreach ($r in $resolved) {
|
||||
$existing = $unlocked | Where-Object { $_.Name -eq $r.Name } | Select-Object -First 1
|
||||
if (-not $existing) { $unlocked.Add([pscustomobject]@{ Name = $r.Name; Code = $r.Code; Key = $r.Key }); $added++ }
|
||||
elseif ($existing.Code -ceq $r.Code) { $same++; $existing | Add-Member -NotePropertyName Key -NotePropertyValue $r.Key -Force }
|
||||
else { if ([string]::IsNullOrEmpty($existing.Code)) { $filled++ } else { " note: replacing the stored code for '$($r.Name)'" }; $existing.Code = $r.Code; $existing | Add-Member -NotePropertyName Key -NotePropertyValue $r.Key -Force }
|
||||
foreach ($i in @($cfg.Installs | Where-Object { $_.Branch -eq $r.Name -and $_.BranchPassword -cne $r.Code })) { $i.BranchPassword = $r.Code; $installsSet++ }
|
||||
}
|
||||
"Unlocked entries: $added added, $filled filled in, $same already correct. Installs given a branch password: $installsSet."
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to save.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
$cfg.Unlocked = @($unlocked)
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
'Config updated. Start CVRManager again.'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<#
|
||||
One-off migration: copy the list of ChilloutVR beta branches you have unlocked in Steam
|
||||
into CVRManager's branch dropdown. Only branch NAMES are copied; no codes (Steam does not keep them in a readable form).
|
||||
|
||||
Reads <Steam>\config\config.vdf -> InstallConfigStore/Software/Valve/Steam/apps/661130, keys "betahash_<branch>".
|
||||
Writes the names to config.json -> Unlocked (skipping names already present).
|
||||
|
||||
Dry run by default. Run with pwsh.
|
||||
pwsh -File 2026-10-05-import-steam-unlocked-branches.ps1 # show what would be added
|
||||
pwsh -File 2026-10-05-import-steam-unlocked-branches.ps1 -Apply
|
||||
|
||||
Close CVRManager first (it rewrites config.json). config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[string]$SteamDir,
|
||||
[switch]$Apply,
|
||||
[switch]$SkipAppCheck # only for testing against a throwaway copy
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $SteamDir) { $SteamDir = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath }
|
||||
if (-not $SteamDir) { throw 'Could not find Steam; pass -SteamDir.' }
|
||||
$vdf = Join-Path $SteamDir 'config\config.vdf'
|
||||
if (-not (Test-Path $vdf)) { throw "No $vdf" }
|
||||
|
||||
function Get-Block([string]$text, [int]$from) {
|
||||
$open = $text.IndexOf('{', $from)
|
||||
if ($open -lt 0) { return $null }
|
||||
$depth = 0
|
||||
for ($i = $open; $i -lt $text.Length; $i++) {
|
||||
if ($text[$i] -eq '{') { $depth++ } elseif ($text[$i] -eq '}') { $depth--; if ($depth -eq 0) { return $text.Substring($open, $i - $open + 1) } }
|
||||
}
|
||||
$null
|
||||
}
|
||||
|
||||
$text = Get-Content $vdf -Raw
|
||||
$apps = [regex]::Match($text, '"apps"\s*\{')
|
||||
if (-not $apps.Success) { throw 'No "apps" section in config.vdf' }
|
||||
$appsBlock = Get-Block $text $apps.Index
|
||||
$app = [regex]::Match($appsBlock, '"661130"\s*\{')
|
||||
if (-not $app.Success) { 'ChilloutVR has no entry in Steam''s config; nothing to import.'; return }
|
||||
$appBlock = Get-Block $appsBlock $app.Index
|
||||
$names = @([regex]::Matches($appBlock, '"betahash_([^"]+)"') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||
"Branches Steam has unlocked for ChilloutVR ($($names.Count)): $($names -join ', ')"
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
if ($Apply -and -not $SkipAppCheck -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$existing = @($cfg.Unlocked | ForEach-Object { $_.Name })
|
||||
$toAdd = @($names | Where-Object { $existing -inotcontains $_ })
|
||||
"Already in CVRManager: $($existing.Count). To add: $($toAdd.Count)$(if ($toAdd.Count) { ' (' + ($toAdd -join ', ') + ')' })"
|
||||
if ($toAdd.Count -eq 0) { 'Nothing to do.'; return }
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to add them.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
$merged = @($cfg.Unlocked) + @($toAdd | ForEach-Object { [pscustomobject]@{ Name = $_; Code = '' } })
|
||||
$cfg.Unlocked = @($merged)
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
"Added $($toAdd.Count) branch name(s). Start CVRManager again."
|
||||
@@ -0,0 +1,66 @@
|
||||
<#
|
||||
One-off repair: point each install in the REAL config.json at its folder on disk, using the <Name>-<id4> format.
|
||||
|
||||
Background: the earlier layout migrations were run from inside Claude's packaged app, whose %APPDATA% writes land in a
|
||||
private copy of the profile. The folders under %LOCALAPPDATA%\CVRManager\installs were really moved, but the real
|
||||
%APPDATA%\CVRManager\config.json was never updated, so the app showed "Not installed". This script fixes the real file.
|
||||
It must be started OUTSIDE Claude's sandbox (a normal terminal, or Win32_Process.Create), otherwise it edits the private copy.
|
||||
|
||||
Each -Map entry is "<folder currently on disk>=<install id, at least 4 chars>".
|
||||
The folder is renamed to <SafeName>-<first 4 of id> and the install's Folder in config.json is set to the new name.
|
||||
|
||||
Dry run by default.
|
||||
pwsh -File 2026-10-05-repair-real-config-folders.ps1 -Map 'Public Scripting-6e3b=9ce8','Public Scripting copy-2f65=1864'
|
||||
pwsh -File ... -Apply
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$Map,
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-SafeFolderName([string]$name) {
|
||||
$bad = [IO.Path]::GetInvalidFileNameChars()
|
||||
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
||||
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
||||
$clean = $clean.Trim().TrimEnd('.')
|
||||
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
||||
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
||||
}
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$installs = Join-Path $lib 'installs'
|
||||
"Config: $configPath"
|
||||
"Library: $lib"
|
||||
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first.' }
|
||||
if ($Apply -and (Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })) { throw 'Close the game first.' }
|
||||
|
||||
$plans = foreach ($entry in $Map) {
|
||||
$dirName, $idPrefix = $entry.Split('=', 2)
|
||||
$inst = @($cfg.Installs | Where-Object { $_.Id.StartsWith($idPrefix, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if ($inst.Count -ne 1) { throw "Id '$idPrefix' matches $($inst.Count) installs." }
|
||||
$inst = $inst[0]
|
||||
if (-not (Test-Path (Join-Path $installs $dirName))) { throw "No folder $dirName in $installs" }
|
||||
[pscustomobject]@{ Install = $inst; From = $dirName; To = "$(Get-SafeFolderName $inst.Name)-$($inst.Id.Substring(0, 4))" }
|
||||
}
|
||||
foreach ($p in $plans) { " $($p.Install.Name) (id $($p.Install.Id)): disk '$($p.From)' -> '$($p.To)'; config Folder '$($p.Install.Folder)' -> '$($p.To)'" }
|
||||
$targets = $plans | ForEach-Object { $_.To }
|
||||
if (($targets | Select-Object -Unique).Count -ne $targets.Count) { throw 'Two installs would get the same folder.' }
|
||||
foreach ($p in $plans) { if ($p.From -ine $p.To -and (Test-Path (Join-Path $installs $p.To))) { throw "Target folder '$($p.To)' already exists." } }
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
foreach ($p in $plans) {
|
||||
if ($p.From -cne $p.To) { [IO.Directory]::Move((Join-Path $installs $p.From), (Join-Path $installs $p.To)) }
|
||||
$p.Install.Folder = $p.To
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
" done: $($p.Install.Name) -> $($p.To)"
|
||||
}
|
||||
'Config updated. Start CVRManager again.'
|
||||
@@ -0,0 +1,59 @@
|
||||
<#
|
||||
One-off migration: the app no longer has a built-in "Menu patch" hook. Your menu patch becomes an ordinary command hook.
|
||||
|
||||
- writes "<library>\hooks\menupatch\apply.ps1": copies the MenuPatch folder (and PatchMenus.bat) from this folder into the game
|
||||
folder, then runs MenuPatch\patch.ps1 there, exiting with its exit code
|
||||
- config.json: the hook with id "menupatch" gets Command = that apply.ps1 and loses its "Kind"
|
||||
|
||||
Your existing master folder (<library>\hooks\menupatch\MenuPatch and PatchMenus.bat) is not touched.
|
||||
Run it OUTSIDE Claude's sandbox (a normal terminal), otherwise it edits a private copy of %APPDATA%.
|
||||
Dry run by default; -Apply writes. Close CVRManager first with -Apply. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$master = Join-Path $lib 'hooks\menupatch'
|
||||
$apply1 = Join-Path $master 'apply.ps1'
|
||||
|
||||
$hook = @($cfg.Hooks) | Where-Object { $_.Id -eq 'menupatch' } | Select-Object -First 1
|
||||
if (-not $hook) { 'No hook with id "menupatch" in the config; nothing to migrate.'; return }
|
||||
"Hook '$($hook.Name)': Kind=$($hook.Kind), Command='$($hook.Command)'"
|
||||
"Master folder: $master (MenuPatch present: $(Test-Path (Join-Path $master 'MenuPatch\patch.ps1')))"
|
||||
"apply.ps1 will be written to: $apply1"
|
||||
|
||||
$script = @'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$game = $env:CVR_GAME_DIR
|
||||
if (-not $game) { throw 'CVR_GAME_DIR is not set; run this from CVRManager.' }
|
||||
$src = Join-Path $PSScriptRoot 'MenuPatch'
|
||||
if (-not (Test-Path (Join-Path $src 'patch.ps1'))) { throw "No MenuPatch\patch.ps1 in $PSScriptRoot" }
|
||||
robocopy $src (Join-Path $game 'MenuPatch') /E /XD backup 'reference-*' /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE)" }
|
||||
$bat = Join-Path $PSScriptRoot 'PatchMenus.bat'
|
||||
if (Test-Path $bat) { Copy-Item $bat (Join-Path $game 'PatchMenus.bat') -Force }
|
||||
& (Join-Path $game 'MenuPatch\patch.ps1')
|
||||
if ($LASTEXITCODE -eq 2) { exit 0 }
|
||||
exit $LASTEXITCODE
|
||||
'@
|
||||
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to save.'; return }
|
||||
|
||||
New-Item -ItemType Directory $master -Force | Out-Null
|
||||
[IO.File]::WriteAllText($apply1, $script, (New-Object Text.UTF8Encoding($false)))
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
$hook.Command = $apply1
|
||||
$hook.Arguments = ''
|
||||
$hook.PSObject.Properties.Remove('Kind')
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
"Config backed up to $backup"
|
||||
'Done. Start CVRManager again.'
|
||||
@@ -0,0 +1,33 @@
|
||||
<#
|
||||
One-off cleanup for configs written by builds before 2026-10-13, which added a built-in "Menu patch" hook to every config.
|
||||
Removes the hook with id "menupatch" when it has no command (the leftover), and drops it from every install.
|
||||
A menupatch hook that has a command (converted by 2026-10-13-menu-patch-to-command-hook.ps1) is left alone.
|
||||
Dry run by default; -Apply writes. Close CVRManager first. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
|
||||
$hook = @($cfg.Hooks) | Where-Object { $_.Id -eq 'menupatch' } | Select-Object -First 1
|
||||
if (-not $hook) { 'No menu patch hook in the config; nothing to do.'; return }
|
||||
if ($hook.Command) { "The menu patch hook runs '$($hook.Command)'; it was set up on purpose, leaving it."; return }
|
||||
$users = @($cfg.Installs | Where-Object { $_.HookIds -contains 'menupatch' } | ForEach-Object Name)
|
||||
"Leftover hook '$($hook.Name)' (no command), enabled on: $(if ($users) { $users -join ', ' } else { 'no installs' })"
|
||||
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to remove it.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
$cfg.Hooks = @($cfg.Hooks | Where-Object { $_.Id -ne 'menupatch' })
|
||||
foreach ($i in $cfg.Installs) { $i.HookIds = @($i.HookIds | Where-Object { $_ -ne 'menupatch' }) }
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
"Config backed up to $backup"
|
||||
'Removed. Start CVRManager again.'
|
||||
@@ -0,0 +1,37 @@
|
||||
<#
|
||||
Builds the app, zips it and publishes a Gitea release that the in-app updater picks up.
|
||||
release.ps1 -Version 1.0.1 -Token <gitea token> builds, tags v1.0.1, creates the release, uploads zip + .sha256
|
||||
release.ps1 -Version 1.0.1 -BuildOnly only writes dist\<version>\CVRManager-win-x64.zip(.sha256)
|
||||
The token needs write:repository.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Version,
|
||||
[string]$Token = $env:GITEA_TOKEN,
|
||||
[string]$GiteaUrl = 'https://git.koneko.cat',
|
||||
[string]$Repo = 'Koneko/CVRManager',
|
||||
[string]$Notes = '',
|
||||
[switch]$BuildOnly
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$out = Join-Path $root "dist\$Version"
|
||||
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
|
||||
dotnet publish "$root\CVRManager.csproj" -c Release -r win-x64 --self-contained false -p:Version=$Version -o "$out\app" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'publish failed' }
|
||||
$zip = Join-Path $out 'CVRManager-win-x64.zip'
|
||||
Compress-Archive -Path "$out\app\*" -DestinationPath $zip
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content "$zip.sha256" "$hash CVRManager-win-x64.zip" -NoNewline
|
||||
"Built $zip ($([math]::Round((Get-Item $zip).Length / 1MB, 1)) MB) sha256 $hash"
|
||||
if ($BuildOnly) { return }
|
||||
if (-not $Token) { throw 'No token: pass -Token or set GITEA_TOKEN.' }
|
||||
|
||||
$h = @{ Authorization = "token $Token" }
|
||||
$api = "$GiteaUrl/api/v1/repos/$Repo"
|
||||
$body = @{ tag_name = "v$Version"; name = "CVR Manager $Version"; body = $Notes; draft = $false; prerelease = $false } | ConvertTo-Json
|
||||
$rel = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $h -ContentType 'application/json' -Body $body
|
||||
foreach ($f in $zip, "$zip.sha256") {
|
||||
Invoke-RestMethod -Method Post -Uri "$api/releases/$($rel.id)/assets?name=$([IO.Path]::GetFileName($f))" -Headers $h -Form @{ attachment = Get-Item $f } | Out-Null
|
||||
}
|
||||
"Published $($rel.html_url)"
|
||||
Reference in New Issue
Block a user