Public/Show-OpenTag3DGui.ps1
|
function Show-OpenTag3DGui { <# .SYNOPSIS Starts a local browser UI for looking up spools, saving tag images and writing tags. .DESCRIPTION Serves a small single-page UI on localhost and opens it in the default browser. The page posts back to Export-OpenTag3DPayload, so it can save a .bin image or write straight to a tag on a PC/SC reader. Uses System.Net.HttpListener, so the UI runs on Windows, Linux and macOS. The tag buttons are enabled when a PC/SC reader is reachable at startup; if none is, the page says why. The listener binds to localhost only. It stops when the page is closed, when you press the Stop button, or with Ctrl+C. The page sends a heartbeat every few seconds; once the first one arrives, the server shuts down if the heartbeat stops for -IdleTimeout seconds. A page reload is not a close: the new page resumes the heartbeat within the grace period. .PARAMETER Port TCP port to listen on. Defaults to 8787. When left at the default, the next 20 ports are tried if that one is taken; naming a port explicitly disables the search so a failure is reported rather than silently moving. .PARAMETER NoBrowser Do not launch a browser; just print the URL. .PARAMETER IdleTimeout Seconds without a heartbeat before the server stops. Defaults to 15. The timer only arms after the first heartbeat, so -NoBrowser sessions are not killed before a page connects. .PARAMETER KeepAlive Never stop automatically; run until Ctrl+C or the Stop button. .EXAMPLE Show-OpenTag3DGui Starts the UI on http://localhost:8787/ and opens it in the default browser. .EXAMPLE Show-OpenTag3DGui -Port 9000 -NoBrowser Starts on a different port and prints the URL instead of launching a browser. Useful over SSH with a forwarded port. #> [CmdletBinding(PositionalBinding = $false)] param( [Parameter()] [ValidateRange(1024, 65535)] [int]$Port = 8787, [Parameter()] [switch]$NoBrowser, [Parameter()] [ValidateRange(5, 3600)] [int]$IdleTimeout = 15, [Parameter()] [switch]$KeepAlive ) # Tag access needs a PC/SC service, not a particular OS: winscard on Windows, pcsc-lite # elsewhere. Probe for a reader rather than assuming from the platform. $canWrite = $true try { $null = Get-PcscReader } catch { $canWrite = $false $pcscError = $_.Exception.Message Write-Verbose "PC/SC unavailable: $pcscError" } $html = Get-OpenTag3DGuiHtml -CanWrite $canWrite -DefaultDir (Get-OpenTag3DDefaultOutputDir) -PcscError $pcscError # HTTP.sys on Windows rejects a prefix already reserved by another registration, and some # ports sit inside Hyper-V/WSL reserved ranges. Try both loopback spellings, and walk up # the port range unless the caller pinned a specific port. $bindHosts = @('localhost','127.0.0.1') $ports = if ($PSBoundParameters.ContainsKey('Port')) { @($Port) } else { $Port..($Port + 20) } $listener = $null $lastErr = $null foreach ($p in $ports) { foreach ($bindHost in $bindHosts) { $candidate = [System.Net.HttpListener]::new() $candidate.Prefixes.Add("http://${bindHost}:$p/") try { $candidate.Start() $listener = $candidate $Port = $p $useHost = $bindHost break } catch { $lastErr = $_.Exception.Message $candidate.Close() } } if ($listener) { break } } if (-not $listener) { $hint = if ($PSBoundParameters.ContainsKey('Port')) { "Try a different port, e.g. Show-OpenTag3DGui -Port $($Port + 113)." } else { "Ports $Port-$($Port + 20) are all unavailable. Pick one explicitly with -Port." } throw @" Could not start the UI listener. $hint Last error: $lastErr On Windows this usually means one of: * another copy of this UI is still running - close it, or check: netstat -ano | findstr $Port * a URL reservation owned by another account - check: netsh http show urlacl | findstr $Port * the port falls in a Hyper-V/WSL reserved range - check: netsh interface ipv4 show excludedportrange protocol=tcp "@ } $url = "http://${useHost}:$Port/" Write-Host "OpenTag3D UI listening on $url (Ctrl+C to stop)" if (-not $canWrite) { Write-Host "No PC/SC reader available, so tag read/write is disabled: $pcscError" Write-Host "Image export and decoding still work." } if (-not $NoBrowser) { try { if ($IsMacOS) { Start-Process 'open' $url } elseif ($IsLinux) { Start-Process 'xdg-open' $url } else { Start-Process $url } } catch { Write-Verbose "Could not launch a browser: $($_.Exception.Message)" } } $stop = $false $reason = 'Stop requested' $lastPing = $null # set by the first heartbeat; until then the idle timer is unarmed $byeAt = $null # set by the page's unload beacon; a reload clears it again try { while ($listener.IsListening -and -not $stop) { # Poll rather than block, so Ctrl+C and the idle timer are honoured between requests. $task = $listener.GetContextAsync() while (-not $task.AsyncWaitHandle.WaitOne(200)) { if (-not $listener.IsListening) { return } if ($KeepAlive -or -not $lastPing) { continue } # A reload fires the unload beacon too, so wait a moment for the new page to # resume its heartbeat before treating the beacon as a real close. if ($byeAt -and ((Get-Date) - $byeAt).TotalSeconds -ge 3) { $stop = $true; $reason = 'Page closed'; break } if (((Get-Date) - $lastPing).TotalSeconds -ge $IdleTimeout) { $stop = $true; $reason = "No heartbeat for $IdleTimeout seconds"; break } } if ($stop) { break } $ctx = $task.GetAwaiter().GetResult() $req = $ctx.Request $path = $req.Url.AbsolutePath Write-Verbose "$($req.HttpMethod) $path" switch -Regex ("$($req.HttpMethod) $path") { '^GET /$' { Write-HttpResponse -Context $ctx -ContentType 'text/html; charset=utf-8' -Body $html break } '^POST /api/(export|write|read|load|apply|profile)$' { $action = $Matches[1] $body = [IO.StreamReader]::new($req.InputStream, $req.ContentEncoding).ReadToEnd() $result = Invoke-OpenTag3DGuiAction -Action $action -Body $body -CanWrite $canWrite Write-HttpResponse -Context $ctx -ContentType 'application/json; charset=utf-8' -Body ($result | ConvertTo-Json -Compress -Depth 6) break } '^POST /api/ping$' { $lastPing = Get-Date $byeAt = $null Write-HttpResponse -Context $ctx -ContentType 'application/json; charset=utf-8' -Body '{"ok":true}' break } '^POST /api/bye$' { $byeAt = Get-Date Write-HttpResponse -Context $ctx -ContentType 'application/json; charset=utf-8' -Body '{"ok":true}' break } '^POST /api/stop$' { # Answer first, then leave the loop; stopping mid-request truncates the reply. Write-HttpResponse -Context $ctx -ContentType 'application/json; charset=utf-8' -Body '{"ok":true}' $stop = $true $reason = 'Stopped from the page' break } default { $ctx.Response.StatusCode = 404 Write-HttpResponse -Context $ctx -ContentType 'text/plain' -Body 'Not found' } } } } finally { if ($listener.IsListening) { $listener.Stop() } $listener.Close() Write-Host "UI stopped. ($reason)" } } |