# ValyraWolves fight log uploader - Windows PowerShell 5.1 (built into Windows), nothing to install. # What it reads: only the *.json fight files in the A2Tools history folder. # What it sends: each new file, once, to the legion app, with your personal token. "Connect" gets the token for you: # it opens the legion site, you log in (or use Telegram) and confirm, the server hands this window a token. # Before every round it asks the server first (a "hello"): is it online, which characters are yours, which fights it # already has. If the server is off (the legion PC is off), nothing is sent until the next check. # The server keeps only fights of your own legion characters and refuses the rest. # First start: Win+R powershell -c "irm https://valyrawolves.webredirect.org/install | iex" # (install.ps1: puts this file in %APPDATA%\ValyraWolves and starts it); it then puts a "ValyraWolves" icon on the desktop. That icon # also starts the A2Tools DPS meter if it is not running yet (untick "Also start the DPS meter" to stop that). # Closing the window keeps it in the tray (next to the clock); right-click the tray icon > Exit to stop it. # It never starts with Windows by itself: no service, no scheduled task. # Updates: when the server has a newer version the window says so, with what changed (also on the site's # /uploader/changes page); it updates only when you press Update, never by itself. Versions the server no longer # accepts are told so and stop uploading. # Settings, the files the server refused and a log file are in %APPDATA%\ValyraWolves. # Instructions: https://valyrawolves.webredirect.org/uploader param([switch]$Installed, [switch]$Updated) # set by install.ps1 / a copy started from elsewhere, or after Update $Version = '2026.11.2.1' # = the newest entry of CHANGES.json (the server compares the two) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Windows.Forms, System.Drawing, Microsoft.VisualBasic [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $Server = 'https://valyrawolves.webredirect.org' $EveryMinutes = 5 $ScriptPath = $PSCommandPath # read here: inside the window's event handlers it can be empty $Dir = Join-Path $env:APPDATA 'ValyraWolves' $InstallPath = Join-Path $Dir 'ValyraWolves.ps1' $SettingsFile = Join-Path $Dir 'settings.json' $RejectedFile = Join-Path $Dir 'rejected.json' # files the server refused, valid for one list of your characters $LogFile = Join-Path $Dir 'log.txt' New-Item -ItemType Directory -Force -Path $Dir | Out-Null function Find-A2Tools { foreach ($p in @("$env:APPDATA\com.a2tools.dps-meter\history", "$env:LOCALAPPDATA\com.a2tools.dps-meter\history")) { if (Test-Path $p) { return $p } } return '' } $script:settings = @{ token = ''; folder = (Find-A2Tools); meter = $true } if (Test-Path $SettingsFile) { $saved = Get-Content $SettingsFile -Raw | ConvertFrom-Json if ($saved.token) { $script:settings.token = $saved.token } if ($saved.folder) { $script:settings.folder = $saved.folder } if ($null -ne $saved.meter) { $script:settings.meter = [bool]$saved.meter } } $script:rejected = New-Object 'System.Collections.Generic.HashSet[string]' $script:rejectedFor = '' if (Test-Path $RejectedFile) { $r = Get-Content $RejectedFile -Raw | ConvertFrom-Json $script:rejectedFor = [string]$r.chars if ($r.files) { foreach ($f in $r.files) { [void]$script:rejected.Add($f) } } } $script:online = $null # unknown until the first hello $script:running = $false $script:busy = $false $script:exiting = $false $script:told = $false $script:pairCode = '' $script:pairUntil = Get-Date $script:toldUpdate = '' # --- install ---------------------------------------------------------------------------------------------- # Normally install.ps1 puts this file in %APPDATA%\ValyraWolves. Started from anywhere else (a file downloaded by hand): # copy it there, start that copy without the console window and close this one. # -Installed (first start of a new copy): make the desktop icon. It runs this file with -ExecutionPolicy Bypass: # Windows does not run downloaded scripts by double-click, Bypass lets this one run, that time only, no setting changed. # Started through "conhost.exe --headless": with Windows Terminal as the default terminal (Windows 11) PowerShell's # -WindowStyle Hidden is ignored and an empty command window stays open; closing it stopped the uploader. # conhost is the classic console host, --headless gives it no window at all (Windows 10 1903 and later). $PsExe = Join-Path $PSHOME 'powershell.exe' $StartArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$InstallPath`"" $Conhost = Join-Path $env:SystemRoot 'System32\conhost.exe' if (Test-Path $Conhost) { $LaunchExe = $Conhost; $LaunchArgs = "--headless `"$PsExe`" $StartArgs" } else { $LaunchExe = $PsExe; $LaunchArgs = $StartArgs } if ($ScriptPath -and $ScriptPath -ne $InstallPath) { Copy-Item -Path $ScriptPath -Destination $InstallPath -Force Unblock-File -Path $InstallPath # our own copy: no "downloaded from the internet" mark Start-Process -FilePath $LaunchExe -ArgumentList "$LaunchArgs -Installed" -WindowStyle Hidden exit } if ($Installed) { $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'ValyraWolves.lnk' $s = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk) $s.TargetPath = $LaunchExe $s.Arguments = $LaunchArgs $s.WorkingDirectory = $Dir $s.IconLocation = $PsExe + ',0' $s.Save() } # --- DPS meter -------------------------------------------------------------------------------------------- function Find-Meter { # the A2Tools DPS meter shortcut, wherever it was installed (Start menu or desktop) $dirs = @('Programs', 'CommonPrograms', 'Desktop', 'CommonDesktopDirectory') | ForEach-Object { [Environment]::GetFolderPath($_) } foreach ($d in $dirs) { if (-not $d -or -not (Test-Path $d)) { continue } $l = Get-ChildItem -Path $d -Filter '*A2Tools*.lnk' -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch 'uninstall' } | Select-Object -First 1 if ($l) { return $l.FullName } } return $null } function Start-Meter { # check first: start it only if it is not running already $lnk = Find-Meter if (-not $lnk) { return 'DPS meter: shortcut not found, start it yourself' } $exe = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath $name = $(if ($exe) { [IO.Path]::GetFileNameWithoutExtension($exe) } else { '' }) $alive = Get-Process -ErrorAction SilentlyContinue | Where-Object { ($name -and $_.ProcessName -eq $name) -or $_.ProcessName -like '*a2tools*dps*' } if ($alive) { return 'DPS meter: already running' } try { Start-Process -FilePath $lnk return 'DPS meter: started' } catch { return "DPS meter: not started ($($_.Exception.Message))" } } # one uploader at a time: a second start (desktop icon while it sits in the tray) only checks the meter and says so $mutex = New-Object Threading.Mutex($false, 'Local\ValyraWolvesUploader') # after Update the old copy is still closing: wait for it a little try { $first = $mutex.WaitOne($(if ($Updated) { 20000 } else { 0 })) } catch [Threading.AbandonedMutexException] { $first = $true } # the last one crashed: fine if (-not $first) { $m = $(if ($script:settings.meter) { "`n" + (Start-Meter) } else { '' }) [void][Windows.Forms.MessageBox]::Show("The uploader is already running: its icon is next to the clock.$m", 'ValyraWolves uploader') exit } # --- window ------------------------------------------------------------------------------------------------- $form = New-Object Windows.Forms.Form $form.Text = 'ValyraWolves uploader' $form.Size = New-Object Drawing.Size(720, 540) $form.MinimumSize = New-Object Drawing.Size(600, 420) $form.Font = New-Object Drawing.Font('Segoe UI', 9) $icon = [Drawing.Icon]::ExtractAssociatedIcon((Join-Path $PSHOME 'powershell.exe')) $form.Icon = $icon function Add-Control($ctl, $x, $y, $w, $h, $anchor = 'Top,Left') { $ctl.Location = New-Object Drawing.Point($x, $y) $ctl.Size = New-Object Drawing.Size($w, $h) $ctl.Anchor = $anchor $form.Controls.Add($ctl) return $ctl } $l1 = Add-Control (New-Object Windows.Forms.Label) 12 17 110 20 $l1.Text = 'Account:' $acct = Add-Control (New-Object Windows.Forms.Label) 125 17 360 20 'Top,Left,Right' $acct.Text = $(if ($script:settings.token) { 'Connected (checking...)' } else { 'Not connected' }) $connectBtn = Add-Control (New-Object Windows.Forms.Button) 490 11 200 30 'Top,Right' $connectBtn.Text = 'Connect' $l2 = Add-Control (New-Object Windows.Forms.Label) 12 53 110 20 $l2.Text = 'A2Tools folder:' $folderBox = Add-Control (New-Object Windows.Forms.TextBox) 125 50 470 24 'Top,Left,Right' $folderBox.Text = $script:settings.folder $browse = Add-Control (New-Object Windows.Forms.Button) 600 49 90 26 'Top,Right' $browse.Text = 'Browse...' $startBtn = Add-Control (New-Object Windows.Forms.Button) 125 84 110 30 $nowBtn = Add-Control (New-Object Windows.Forms.Button) 240 84 110 30 $nowBtn.Text = 'Upload now' $logBtn = Add-Control (New-Object Windows.Forms.Button) 355 84 110 30 $logBtn.Text = 'Open log file' $pasteBtn = Add-Control (New-Object Windows.Forms.Button) 470 84 110 30 $pasteBtn.Text = 'Paste token...' $meterBox = Add-Control (New-Object Windows.Forms.CheckBox) 125 120 200 22 $meterBox.Text = 'Also start the DPS meter' $meterBox.Checked = $script:settings.meter $meterLabel = Add-Control (New-Object Windows.Forms.Label) 330 123 360 20 'Top,Left,Right' $status = Add-Control (New-Object Windows.Forms.Label) 12 150 680 20 'Top,Left,Right' $list = Add-Control (New-Object Windows.Forms.ListBox) 12 174 678 313 'Top,Bottom,Left,Right' $list.HorizontalScrollbar = $true # update bar, above the status line: shown only when the server has a newer version $updBar = Add-Control (New-Object Windows.Forms.Panel) 12 146 678 34 'Top,Left,Right' $updBar.BackColor = [Drawing.Color]::FromArgb(255, 243, 214) $updBar.Visible = $false $updText = New-Object Windows.Forms.Label $updText.Location = New-Object Drawing.Point(8, 9); $updText.Size = New-Object Drawing.Size(440, 18); $updText.Anchor = 'Top,Left,Right' $updText.AutoEllipsis = $true $newsBtn = New-Object Windows.Forms.Button $newsBtn.Location = New-Object Drawing.Point(454, 4); $newsBtn.Size = New-Object Drawing.Size(110, 26); $newsBtn.Anchor = 'Top,Right' $newsBtn.Text = "What's new" $updBtn = New-Object Windows.Forms.Button $updBtn.Location = New-Object Drawing.Point(568, 4); $updBtn.Size = New-Object Drawing.Size(104, 26); $updBtn.Anchor = 'Top,Right' $updBtn.Text = 'Update' $updBar.Controls.AddRange(@($updText, $newsBtn, $updBtn)) $script:update = $null # tray icon: closing the window keeps the uploader running there $tray = New-Object Windows.Forms.NotifyIcon $tray.Icon = $icon $tray.Text = 'ValyraWolves uploader' $tray.Visible = $true $menu = New-Object Windows.Forms.ContextMenuStrip $openItem = $menu.Items.Add('Open') $uploadItem = $menu.Items.Add('Upload now') $exitItem = $menu.Items.Add('Exit') $tray.ContextMenuStrip = $menu function Write-Log($text) { $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $text" $list.Items.Insert(0, $line) if ($list.Items.Count -gt 500) { $list.Items.RemoveAt(500) } Add-Content -Path $LogFile -Value $line -Encoding UTF8 } function Set-Running($on) { $script:running = $on $startBtn.Text = $(if ($on) { 'Pause' } else { 'Start' }) $status.Text = $(if ($on) { "Running: checks for new fights every $EveryMinutes minutes." } else { 'Paused. Press Start to upload.' }) } # --- install, connect --------------------------------------------------------------------------------------- function New-PairCode { $b = New-Object byte[] 24 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b) return [Convert]::ToBase64String($b).Replace('+', '-').Replace('/', '_').TrimEnd('=') } # --- upload ------------------------------------------------------------------------------------------------- function Invoke-Server($method, $path, $file = $null) { # -> the server's JSON answer; if it cannot be reached: status = offline $req = @{ Uri = "$Server$path"; Method = $method; TimeoutSec = 120 Headers = @{ Authorization = "Bearer $($script:settings.token)"; 'X-Uploader-Version' = $Version } } if ($file) { $req.ContentType = 'application/json'; $req.InFile = $file } try { return Invoke-RestMethod @req } catch { $err = $_ $answer = $null if ($err.ErrorDetails -and $err.ErrorDetails.Message) { try { $answer = $err.ErrorDetails.Message | ConvertFrom-Json } catch { } } if ($answer -and $answer.status) { return $answer } return [pscustomobject]@{ status = 'offline'; reason = $err.Exception.Message } } } function Set-Online($on, $why) { if ($script:online -ne $on) { # log only when it changes, not every 5 minutes if ($on) { Write-Log 'Server online.' } else { Write-Log "Server offline ($why). No uploads until it is back; checking every $EveryMinutes minutes." } } $script:online = $on } function Show-Update($u, $required) { # the bar above the status line; $required: this version no longer works, uploads are stopped $script:update = $u $on = [bool]$u $updBar.Visible = $on $status.Top = $(if ($on) { 186 } else { 150 }) $list.Top = $(if ($on) { 210 } else { 174 }) $list.Height = $form.ClientSize.Height - $list.Top - 12 if (-not $on) { return } $updBar.BackColor = $(if ($required) { [Drawing.Color]::FromArgb(255, 222, 218) } else { [Drawing.Color]::FromArgb(255, 243, 214) }) $updText.Text = $(if ($required) { "Update needed (this version no longer works): $($u.tldr)" } else { "Update available ($($u.version)): $($u.tldr)" }) if ($script:toldUpdate -ne $u.version) { # once per version in the log and next to the clock $script:toldUpdate = $u.version Write-Log "New version $($u.version): $($u.tldr) Press What's new / Update." $tray.ShowBalloonTip(5000, 'ValyraWolves uploader', "New version: $($u.tldr)", 'Info') } } function Show-News { $u = $script:update if (-not $u) { return } $text = "You have version $Version. What changed since then:`n" foreach ($c in @($u.changes)) { $text += "`n$($c.version) ($($c.date))$(if ($c.breaking) { ' - needed: older versions stop working' })`n$($c.tldr)`n" foreach ($d in @($c.details)) { $text += " - $d`n" } } $text += "`nAll versions: $Server/uploader/changes`nThe new script, as plain text: $Server$($u.url)`n`nUpdate now?" if ([Windows.Forms.MessageBox]::Show($text, "ValyraWolves uploader - what's new", 'YesNo', 'Information') -eq 'Yes') { Install-Update } } function Install-Update { # download next to the old one, check it is exactly the file the server announced, swap, restart $u = $script:update $new = "$InstallPath.new" try { Invoke-WebRequest -Uri "$Server$($u.url)" -OutFile $new -UseBasicParsing -TimeoutSec 60 $sha = (Get-FileHash -Path $new -Algorithm SHA256).Hash.ToLower() if ($sha -ne $u.sha256) { Remove-Item $new -Force; Write-Log 'Update: the download does not match the announced version, try again later.'; return } Move-Item -Path $new -Destination $InstallPath -Force } catch { Write-Log "Update failed: $($_.Exception.Message)"; return } Write-Log "Updated to $($u.version), restarting." Start-Process -FilePath $LaunchExe -ArgumentList "$LaunchArgs -Updated" -WindowStyle Hidden $script:exiting = $true $form.Close() } function Save-Rejected { @{ chars = $script:rejectedFor; files = @($script:rejected) } | ConvertTo-Json | Set-Content -Path $RejectedFile -Encoding UTF8 } function Upload-New { if ($script:busy) { return } $script:busy = $true try { if (-not $script:settings.token) { $status.Text = 'Press "Connect" to link your account.'; return } if (-not (Test-Path $script:settings.folder)) { Write-Log "Folder not found: $($script:settings.folder)"; return } # 1. hello: is the server there, who am I, what does it already have $status.Text = 'Checking the server...' [Windows.Forms.Application]::DoEvents() $h = Invoke-Server 'Get' '/upload/hello' if ($h.status -eq 'outdated') { Show-Update $h.update $true; Write-Log "Stopped: $($h.reason)"; Set-Running $false; return } Show-Update $h.update $false if ($h.status -eq 'unauthorized') { Write-Log "Stopped: $($h.reason)"; $acct.Text = 'Not connected: press Connect'; Set-Running $false; return } if ($h.status -ne 'ok') { Set-Online $false $h.reason; $status.Text = "Server offline. Next check in $EveryMinutes minutes."; return } Set-Online $true $acct.Text = "Connected as $($h.who) - $(@($h.chars) -join ', ')" $have = New-Object 'System.Collections.Generic.HashSet[string]' if ($h.have) { foreach ($f in $h.have) { [void]$have.Add($f) } } $chars = (@($h.chars) -join ', ') if ($script:rejectedFor -ne $chars) { # your characters changed: refused files may be yours now if ($script:rejected.Count) { Write-Log "Your characters are now: $chars. Retrying the files refused before." } $script:rejected.Clear(); $script:rejectedFor = $chars; Save-Rejected } # 2. send only what the server does not have (and did not refuse for these characters) $cutoff = (Get-Date).AddMinutes(-1) # files newer than this may still be written by the meter $todo = @(Get-ChildItem -Path $script:settings.folder -Filter '*.json' -File | Where-Object { -not $have.Contains($_.BaseName) -and -not $script:rejected.Contains($_.BaseName) -and $_.LastWriteTime -lt $cutoff } | Sort-Object LastWriteTime) $base = "Server online - $($have.Count) fights stored for $chars" if ($todo.Count -eq 0) { $status.Text = "$base. Nothing new; next check in $EveryMinutes minutes."; return } $ok = 0; $skip = 0; $n = 0 foreach ($f in $todo) { if (-not $script:running) { break } $n++ $status.Text = "$base. Uploading $n of $($todo.Count)..." [Windows.Forms.Application]::DoEvents() $r = Invoke-Server 'Post' '/upload' $f.FullName if ($r.status -eq 'accepted') { $ok++; [void]$have.Add($f.BaseName); Write-Log "OK $($r.char) - $($r.boss) - $($r.dps) DPS" } elseif ($r.status -eq 'duplicate') { [void]$have.Add($f.BaseName) } elseif ($r.status -eq 'rejected') { $skip++; [void]$script:rejected.Add($f.BaseName); Save-Rejected; Write-Log "Skipped $($f.Name): $($r.reason)" } elseif ($r.status -eq 'unauthorized' -or $r.status -eq 'outdated') { Write-Log "Stopped: $($r.reason)"; Set-Running $false; return } else { Set-Online $false $r.reason; break } # server gone mid-round: stop, the next hello decides Start-Sleep -Milliseconds 200 # gentle with the server } Write-Log "Round done: $ok uploaded, $skip skipped, $($todo.Count - $ok - $skip) left for later." $status.Text = "Server online - $($have.Count) fights stored for $chars. Next check in $EveryMinutes minutes." } catch { Write-Log "Error: $($_.Exception.Message)" } finally { $script:busy = $false } } function Save-Settings { $script:settings.folder = $folderBox.Text.Trim() $script:settings.meter = $meterBox.Checked $script:settings | ConvertTo-Json | Set-Content -Path $SettingsFile -Encoding UTF8 } function Use-Token($t) { $script:settings.token = $t Save-Settings $acct.Text = 'Connected (checking...)' Set-Running $true Upload-New } # --- events ------------------------------------------------------------------------------------------------- $timer = New-Object Windows.Forms.Timer $timer.Interval = $EveryMinutes * 60 * 1000 $timer.Add_Tick({ if ($script:running) { Upload-New } }) # after "Connect": ask the server every 3 s whether you confirmed on the site or in the bot (10 minutes at most) $pairTimer = New-Object Windows.Forms.Timer $pairTimer.Interval = 3000 $pairTimer.Add_Tick({ if ((Get-Date) -gt $script:pairUntil) { $pairTimer.Stop(); $connectBtn.Enabled = $true $acct.Text = 'Not connected: press Connect again' return } try { $r = Invoke-RestMethod -Uri "$Server/upload/pair?code=$($script:pairCode)" -TimeoutSec 10 } catch { return } if ($r.status -eq 'ok') { $pairTimer.Stop(); $connectBtn.Enabled = $true Write-Log 'Connected to your legion account.' Use-Token $r.token } }) $connectBtn.Add_Click({ $script:pairCode = New-PairCode $script:pairUntil = (Get-Date).AddMinutes(10) $connectBtn.Enabled = $false $acct.Text = 'Waiting: confirm in the browser page that just opened...' Start-Process "$Server/connect?code=$($script:pairCode)" $pairTimer.Start() }) $pasteBtn.Add_Click({ $t = [Microsoft.VisualBasic.Interaction]::InputBox('Paste the token from your Account page (or /token in the bot):', 'ValyraWolves uploader', '') if ($t.Trim()) { Use-Token $t.Trim() } }) $browse.Add_Click({ $d = New-Object Windows.Forms.FolderBrowserDialog $d.Description = 'A2Tools history folder (com.a2tools.dps-meter\history)' if ($folderBox.Text -and (Test-Path $folderBox.Text)) { $d.SelectedPath = $folderBox.Text } if ($d.ShowDialog() -eq 'OK') { $folderBox.Text = $d.SelectedPath; Save-Settings } }) $meterBox.Add_CheckedChanged({ Save-Settings }) $startBtn.Add_Click({ if ($script:running) { Set-Running $false; return } Save-Settings Set-Running $true Upload-New }) $nowBtn.Add_Click({ Save-Settings; if (-not $script:running) { Set-Running $true }; Upload-New }) $logBtn.Add_Click({ if (Test-Path $LogFile) { Start-Process notepad.exe $LogFile } }) $newsBtn.Add_Click({ Show-News }) $updBtn.Add_Click({ Install-Update }) function Show-Window { $form.Show(); $form.WindowState = 'Normal'; $form.Activate() } $tray.Add_DoubleClick({ Show-Window }) $openItem.Add_Click({ Show-Window }) $uploadItem.Add_Click({ if (-not $script:running) { Set-Running $true }; Upload-New }) $exitItem.Add_Click({ $script:exiting = $true; $form.Close() }) $form.Add_FormClosing({ param($sender, $e) if (-not $script:exiting -and $e.CloseReason -eq 'UserClosing') { # the X button: keep running in the tray $e.Cancel = $true $form.Hide() if (-not $script:told) { # once per start $tray.ShowBalloonTip(5000, 'ValyraWolves uploader', 'Still uploading in the background. Right-click this icon > Exit to stop it.', 'Info') $script:told = $true } return } $timer.Stop(); $pairTimer.Stop(); $tray.Visible = $false; $tray.Dispose(); $mutex.ReleaseMutex() }) $form.Add_Shown({ if ($Installed) { Write-Log "Installed in $Dir, with a ValyraWolves icon on your desktop: next time just double-click it." } Write-Log "Uploader $Version started. Folder: $($script:settings.folder)" if ($script:settings.meter) { $meterLabel.Text = Start-Meter; Write-Log $meterLabel.Text } if (-not $script:settings.folder) { Write-Log 'A2Tools folder not found: press Browse and pick its history folder.' } $timer.Start() if (-not $script:settings.token) { Set-Running $false; $status.Text = 'Press "Connect" to link your account.' } elseif ($script:settings.folder) { Set-Running $true; Upload-New } else { Set-Running $false } }) [Windows.Forms.Application]::Run($form) # not ShowDialog: hiding a dialog would end it