Private/GuiSupport.ps1
|
# Support for Show-OpenTag3DGui: response writing, request dispatch, and the page itself. function Write-HttpResponse { [CmdletBinding()] param( [Parameter(Mandatory)] $Context, [Parameter(Mandatory)] [string]$ContentType, [Parameter(Mandatory)] [string]$Body ) try { $bytes = [Text.Encoding]::UTF8.GetBytes($Body) $Context.Response.ContentType = $ContentType $Context.Response.ContentLength64 = $bytes.Length $Context.Response.Headers.Add('Cache-Control','no-store') $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) $Context.Response.OutputStream.Close() } catch { # Client went away, or the listener already answered (e.g. 411 on a bodyless POST). # Normal for a web server; never worth taking the UI down for. Write-Verbose "Could not send response: $($_.Exception.Message)" } } function Invoke-OpenTag3DGuiAction { <# .SYNOPSIS Runs one export or write on behalf of the UI and returns a result object. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('export','write','read','load','apply','profile')] [string]$Action, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Body, [Parameter(Mandatory)] [bool]$CanWrite ) if ([string]::IsNullOrWhiteSpace($Body)) { return @{ ok = $false; message = 'Empty request.' } } try { $r = $Body | ConvertFrom-Json } catch { return @{ ok = $false; message = 'Malformed request.' } } # --- load: fetch, read or build a payload and return it as editable fields --- if ($Action -eq 'load') { try { $mode = if ("$($r.mode)" -in 'Core','Extended') { "$($r.mode)" } elseif ($r.tagType -eq 'NTAG213') { 'Core' } else { 'Extended' } $wanted = if ("$($r.specVersion)" -in $script:OpenTag3DSpecVersions) { "$($r.specVersion)" } else { $script:OpenTag3DDefaultSpecVersion } # A hand-built tag has no lookup behind it, so its serial is the vendor's own # batch id and stays editable. A Polar payload keeps the serial locked: it is # the key the data came from. $generic = $false $converted = $null if ($r.source -eq 'tag') { if (-not $CanWrite) { return @{ ok = $false; message = 'No PC/SC reader available.' } } $p = @{} if ($r.readerName) { $p.ReaderName = $r.readerName } $tag = Read-OpenTag3DTag @p -Raw $payload = $tag.PayloadBytes } elseif ($r.source -eq 'new') { $generic = $true $payload = New-OpenTag3DBlankPayload -Mode $mode -SpecVersion $wanted } elseif ($r.source -eq 'profile') { $generic = $true if ([string]::IsNullOrWhiteSpace($r.profileName)) { return @{ ok = $false; message = 'Choose a profile to load.' } } $prof = Get-OpenTag3DProfile -Name $r.profileName if (-not ("$($r.mode)" -in 'Core','Extended')) { $mode = $prof.Mode } # A profile is a set of values, not a layout: it loads into whichever version # is selected, and the encoder ignores ids that version does not have. $payload = New-OpenTag3DGenericPayload -Values $prof.Values -Mode $mode -SpecVersion $wanted } else { if ([string]::IsNullOrWhiteSpace($r.serial)) { return @{ ok = $false; message = 'Enter a spool serial.' } } # -PassThru keeps this off the disk entirely. $img = Export-OpenTag3DPayload -TagType $r.tagType -Serial $r.serial -Mode $mode -Format Ndef -PassThru -InformationAction SilentlyContinue -WarningAction SilentlyContinue $payload = Get-OpenTag3DNdefPayload -UserMemory $img # The service picks the version; convert only if the page asked for the other. $served = Get-OpenTag3DPayloadVersion -Payload $payload if ($served -and $served -ne $wanted) { $payload = Convert-OpenTag3DPayload -Payload $payload -ToSpecVersion $wanted -FromSpecVersion $served -WarningVariable convWarn -WarningAction SilentlyContinue $converted = "Converted from OpenTag3D $served to $wanted." + $(if ($convWarn) { ' ' + (($convWarn | ForEach-Object { "$_" }) -join ' ') } else { '' }) } } $editable = @(Get-OpenTag3DEditableField -Payload $payload -AllowSerial:$generic -BlankUnset:$generic) $actual = Get-OpenTag3DPayloadVersion -Payload $payload $byId = @{} foreach ($e in $editable) { $byId[$e.id] = $e.value } $title = (@($byId['material'], $byId['material_mod'], $byId['color_name']) | Where-Object { $_ }) -join " $([char]0x00B7) " if (-not $title) { $title = if ($r.source -eq 'profile') { "Profile $([char]0x00B7) $($r.profileName)" } elseif ($generic) { 'New tag (generic vendor)' } else { 'Tag data' } } return @{ ok = $true fields = @($editable) payloadHex = ([BitConverter]::ToString($payload) -replace '-') generic = $generic mode = $mode specVersion = $actual hasModes = (Get-OpenTag3DSpec -SpecVersion $actual).HasModes note = $converted title = $title } } catch { return @{ ok = $false; message = $_.Exception.Message } } } # --- profile: list, save and delete saved vendor profiles --- if ($Action -eq 'profile') { try { switch ("$($r.op)") { 'list' { return @{ ok = $true; profiles = @(Get-OpenTag3DProfileList) } } 'save' { $values = @{} if ($r.values) { foreach ($kv in $r.values.PSObject.Properties) { $values[$kv.Name] = $kv.Value } } if ($values.Count -eq 0) { return @{ ok = $false; message = 'Nothing to save.' } } $mode = if ("$($r.mode)" -in 'Core','Extended') { "$($r.mode)" } else { 'Extended' } $type = if ("$($r.tagType)" -in 'NTAG213','NTAG215','NTAG216') { "$($r.tagType)" } else { 'NTAG215' } $sv = if ("$($r.specVersion)" -in $script:OpenTag3DSpecVersions) { "$($r.specVersion)" } else { $script:OpenTag3DDefaultSpecVersion } $path = Save-OpenTag3DProfile -Name "$($r.name)" -Values $values -TagType $type -Mode $mode -SpecVersion $sv return @{ ok = $true; message = "Saved profile to $path"; profiles = @(Get-OpenTag3DProfileList); name = "$($r.name)" } } 'delete' { Remove-OpenTag3DProfile -Name "$($r.name)" return @{ ok = $true; message = "Deleted profile '$($r.name)'."; profiles = @(Get-OpenTag3DProfileList) } } default { return @{ ok = $false; message = "Unknown profile operation '$($r.op)'." } } } } catch { return @{ ok = $false; message = $_.Exception.Message } } } # --- apply: encode edited values and either save or write --- if ($Action -eq 'apply') { try { if ([string]::IsNullOrWhiteSpace($r.payloadHex)) { return @{ ok = $false; message = 'Nothing loaded to save.' } } $hex = $r.payloadHex $base = [byte[]]::new($hex.Length / 2) for ($i = 0; $i -lt $base.Length; $i++) { $base[$i] = [Convert]::ToByte($hex.Substring($i*2,2),16) } # Read-only fields are enforced here, not just disabled in the browser: a hand-made # request must not be able to rewrite the serial on a payload that came from a # lookup. A hand-built tag owns its serial, so only the tag version is fixed. $readOnly = if ($r.generic) { $script:OpenTag3DGenericReadOnly } else { $script:OpenTag3DReadOnly } $values = @{} foreach ($kv in $r.values.PSObject.Properties) { if ($kv.Name -in $readOnly) { continue } $values[$kv.Name] = $kv.Value } # Encode once, untruncated: the truncation preview, the payload written and the # file name all come from this. $full = ConvertTo-OpenTag3DPayload -BasePayload $base -Values $values -WarningAction SilentlyContinue # The base payload carries its own version, so the encoder above used the right # table. Everything from here on has to agree with it. $version = Get-OpenTag3DPayloadVersion -Payload $base $spec = Get-OpenTag3DSpec -SpecVersion $version if ($r.tagType -eq 'NTAG213' -and $spec.Major -ge 2) { return @{ ok = $false; message = "OpenTag3D $version cannot be written to an NTAG213: the payload is $($full.Length) bytes against 144 bytes of user memory. Choose NTAG215 or NTAG216, or switch the spec version to 1.003." } } # NTAG213 holds 1.003 Core only; cut the payload at 0x70. Anything populated above # that address is lost, so say what will go and require an explicit confirmation. $truncate = if ($spec.HasModes -and $r.tagType -eq 'NTAG213' -and $full.Length -gt 112) { 112 } else { 0 } if ($truncate) { $all = @(ConvertFrom-OpenTag3DPayload -Payload $full | Select-Object -ExpandProperty Fields) $dropping = @($all | Where-Object { $_.Section -eq 'Extended' }) $keeping = @($all | Where-Object { $_.Section -ne 'Extended' }) if ($dropping.Count -and -not $r.confirmTruncate) { return @{ ok = $false confirm = 'truncate' message = "NTAG213 stores OpenTag3D Core only. These $($keeping.Count) field(s) are all that will be kept:" keeping = @($keeping | ForEach-Object { @{ name = $_.Name; value = "$($_.Value)" } }) dropped = $dropping.Count } } } $payload = if ($truncate) { [byte[]]$full[0..($truncate - 1)] } else { $full } $record = New-OpenTag3DNdefRecord -Payload $payload $image = New-OpenTag3DImage -Data $record -TagType $r.tagType -Format Ndef # 2.x marks ten fields required. An incomplete tag is still a tag, so this is # said rather than enforced. $missing = @(Get-OpenTag3DMissingRequiredField -Values $values -SpecVersion $version) $warn = if ($missing.Count) { " Required by $($version) but left empty: $($missing -join ', ')." } else { '' } if ($r.target -eq 'tag') { if (-not $CanWrite) { return @{ ok = $false; message = 'No PC/SC reader available.' } } $p = @{ Bytes = $image } if ($r.readerName) { $p.ReaderName = $r.readerName } # Migrating a tag from one spec version to another is deliberate, so the # cmdlet refuses by default. Rather than duplicate the check here, let it # refuse and turn its structured error into a confirmation for the page; # confirming comes back with -Force. if ($r.confirmMigrate) { $p.Force = $true } try { $text = (Write-OpenTag3DTag @p 6>&1 3>&1 | Out-String).Trim() } catch { if ($_.FullyQualifiedErrorId -notlike 'SpecVersionMismatch*' -or $r.confirmMigrate) { throw } $d = $_.TargetObject return @{ ok = $false confirm = 'version' message = "This spool holds OpenTag3D $($d.TagVersion). Writing this image rewrites it as $($d.ImageVersion)." tagVersion = "$($d.TagVersion)" imageVersion = "$($d.ImageVersion)" upgrade = [bool]$d.Upgrade why = "$($d.Why)" lost = @($d.Lost) } } $msg = if ($text) { $text } else { 'Written.' } return @{ ok = $true; message = $msg + $warn } } $dir = if ($r.outputDir) { $r.outputDir } else { Get-OpenTag3DDefaultOutputDir } if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return @{ ok = $false; message = "Output directory not found: $dir" } } # Name from the untruncated payload: an NTAG213 cut at 0x70 has no serial field. $decoded = ConvertFrom-OpenTag3DPayload -Payload $full $parts = if ($decoded.serial) { @($decoded.serial) } else { @($decoded.manufacturer, $decoded.material, $decoded.color_name) } $name = (@($parts | Where-Object { $_ }) -join '-') $name = ($name -replace '[^A-Za-z0-9._-]+', '-').Trim('-') if (-not $name) { $name = 'opentag3d' } # Name the version outright rather than only when it is not the default: with two # formats in routine use, an unlabelled file is ambiguous a month later. $label = if ($r.generic) { 'Generic' } else { 'Edited' } $label = "$label-$version" $path = Join-Path $dir "$name-$($r.tagType)-$label-Ndef.bin" [IO.File]::WriteAllBytes($path, $image) return @{ ok = $true; message = "Wrote $($image.Length) bytes to $path" + $warn } } catch { return @{ ok = $false; message = $_.Exception.Message } } } if ($Action -eq 'read') { if (-not $CanWrite) { return @{ ok = $false; message = 'No PC/SC reader available.' } } try { $p = @{} if ($r.readerName) { $p.ReaderName = $r.readerName } $tag = Read-OpenTag3DTag @p # Group headings are version-specific - Core/Extended for 1.003, Display / # Inventory / Operational for 2.x - so the page is told them rather than # assuming a set and silently dropping every row that does not match. $spec = Get-OpenTag3DSpec -SpecVersion $tag.SpecVersion return @{ ok = $true fields = @($tag.Fields | ForEach-Object { @{ name = $_.Name; value = "$($_.Value)"; section = $_.Section } }) groups = @($spec.GroupOrder) specVersion = $tag.SpecVersion color = "$($tag.color_1)" title = (@($tag.material, $tag.material_mod, $tag.color_name) | Where-Object { $_ }) -join " $([char]0x00B7) " } } catch { return @{ ok = $false; message = $_.Exception.Message } } } if ([string]::IsNullOrWhiteSpace($r.serial)) { return @{ ok = $false; message = 'Enter a spool serial.' } } if ($Action -eq 'write' -and -not $CanWrite) { return @{ ok = $false; message = 'No PC/SC reader available.' } } $p = @{ TagType = $r.tagType Serial = $r.serial Format = $r.format } if ($r.mode) { $p.Mode = $r.mode } # blank means "let the cmdlet default per tag type" if ($Action -eq 'write') { $p.WriteToTag = $true if ($r.readerName) { $p.ReaderName = $r.readerName } } elseif ($r.outputDir) { $p.OutputDir = $r.outputDir } try { # Write-Host output from the cmdlets goes to the information stream; capture it for the page. $text = (Export-OpenTag3DPayload @p 6>&1 3>&1 | Out-String).Trim() return @{ ok = $true; message = if ($text) { $text } else { 'Done.' } } } catch { return @{ ok = $false; message = $_.Exception.Message } } } function Get-OpenTag3DGuiHtml { [CmdletBinding()] param( [Parameter(Mandatory)] [bool]$CanWrite, [Parameter(Mandatory)] [string]$DefaultDir, [string]$PcscError ) $page = @' <!doctype html> <meta charset="utf-8"> <title>OpenTag3D RFID Writer</title> <style> :root { color-scheme: light dark; --edge:#8883; --accent:#c2410c; --bg:#ffffff; --fg:#141414; --field:#ffffff; --field-fg:#141414; } @media (prefers-color-scheme: dark) { :root { --bg:#1b1b1d; --fg:#ececec; --field:#2a2a2e; --field-fg:#ececec; } } body { font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; max-width:33rem; margin:3rem auto; padding:0 1.25rem; background:var(--bg); color:var(--fg); } h1 { font-size:1.3rem; margin:0 0 .25rem; letter-spacing:-.01em; } p.sub { margin:0 0 1.75rem; opacity:.65; font-size:.9rem; } fieldset { border:1px solid var(--edge); border-radius:8px; padding:1rem 1.1rem 1.2rem; margin:0 0 1.1rem; } legend { padding:0 .4rem; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; opacity:.6; } label { display:block; margin:.7rem 0 .2rem; font-size:.85rem; opacity:.8; } input,select { width:100%; box-sizing:border-box; padding:.5rem .6rem; font:inherit; border:1px solid var(--edge); border-radius:6px; background:var(--field); color:var(--field-fg); } /* Native option popups paint their own background, so set both explicitly or dark mode ends up white-on-white. */ option { background:var(--field); color:var(--field-fg); } input::placeholder { color:var(--fg); opacity:.45; } input:focus,select:focus { outline:2px solid var(--accent); outline-offset:1px; } /* Wraps rather than squeezing: the edit screen's row carries four controls since the spec version selector arrived, and four across truncates their labels. */ .row { display:flex; gap:.75rem; flex-wrap:wrap; } .row > div { flex:1 1 9rem; min-width:0; } .actions { display:flex; gap:.6rem; margin-top:1.4rem; align-items:center; } button { padding:.55rem 1.1rem; font:inherit; border-radius:6px; border:1px solid var(--edge); background:var(--field); color:var(--field-fg); cursor:pointer; } button.primary { background:var(--accent); border-color:var(--accent); color:#fff; } button:disabled { opacity:.4; cursor:not-allowed; } #out { margin-top:1.3rem; padding:.8rem .9rem; border-radius:6px; border:1px solid var(--edge); background:var(--field); color:var(--field-fg); white-space:pre-wrap; font:13px ui-monospace,SFMono-Regular,Consolas,monospace; display:none; } #out.ok { border-color:#16a34a88; } #out.err { border-color:#dc262688; } .note { font-size:.82rem; opacity:.7; margin:.6rem 0 0; } #stop { margin-left:auto; opacity:.55; font-size:.85rem; } #tag { margin-top:1.3rem; display:none; } #tag h2 { font-size:1rem; margin:0 0 .2rem; display:flex; align-items:center; gap:.5rem; } #tag .swatch { width:1rem; height:1rem; border-radius:3px; border:1px solid var(--edge); display:inline-block; } #tag .ver { font-size:.72rem; font-weight:400; opacity:.55; letter-spacing:.04em; text-transform:uppercase; margin-left:auto; } #tag table { width:100%; border-collapse:collapse; font-size:.86rem; } #tag th { text-align:left; font-weight:600; opacity:.55; font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; padding:.9rem 0 .3rem; border-bottom:1px solid var(--edge); } #tag td { padding:.3rem .5rem .3rem 0; border-bottom:1px solid var(--edge); vertical-align:top; } #tag td.k { opacity:.7; width:45%; } .tabs { display:flex; gap:.4rem; margin:0 0 1.2rem; border-bottom:1px solid var(--edge); } .tab { border:none; border-bottom:2px solid transparent; border-radius:0; background:none; padding:.5rem .8rem; opacity:.6; font-size:.9rem; } .tab.active { opacity:1; border-bottom-color:var(--accent); font-weight:600; } .fgroup { margin:1.1rem 0 .2rem; font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; opacity:.55; border-bottom:1px solid var(--edge); padding-bottom:.3rem; } .frow { display:flex; align-items:center; gap:.6rem; margin:.35rem 0; } .frow label { flex:0 0 45%; margin:0; font-size:.84rem; opacity:.85; } .frow input { flex:1; padding:.35rem .5rem; } .frow input[readonly] { opacity:.55; cursor:not-allowed; } /* Colour rows: hex text stays authoritative, the picker and the clear button drive it. */ .frow input[type=color] { flex:0 0 2.1rem; width:2.1rem; height:1.9rem; padding:1px; cursor:pointer; background:var(--field); } .frow input[type=color]::-webkit-color-swatch-wrapper { padding:1px; } .frow input[type=color]::-webkit-color-swatch { border:none; border-radius:3px; } .frow .clr { flex:0 0 auto; padding:.15rem .45rem; font-size:.85rem; line-height:1.2; opacity:.6; } .frow .clr:hover { opacity:1; } .frow .unset { opacity:.35; } .prow { display:flex; gap:.5rem; align-items:flex-end; } .prow > div { flex:1; } .prow button { flex:0 0 auto; } #editOut { margin-top:1.2rem; padding:.8rem .9rem; border-radius:6px; border:1px solid var(--edge); background:var(--field); color:var(--field-fg); white-space:pre-wrap; font:13px ui-monospace,SFMono-Regular,Consolas,monospace; display:none; } #editOut.ok { border-color:#16a34a88; } #editOut.err { border-color:#dc262688; } #confirm { margin-top:1.2rem; padding:.9rem 1rem; border-radius:6px; border:1px solid #d9770688; background:var(--field); color:var(--field-fg); display:none; } #confirm h3 { margin:0 0 .4rem; font-size:.92rem; } #confirm ul { margin:.5rem 0 .9rem; padding-left:1.1rem; font:13px ui-monospace,SFMono-Regular,Consolas,monospace; } #confirm li { margin:.12rem 0; } #confirm .actions { margin-top:.8rem; } </style> <h1>OpenTag3D RFID Writer</h1> <p class="sub">Look up a spool or build a tag by hand, then save an image or write a tag.</p> <nav class="tabs"> <button class="tab active" data-screen="main">Fetch & write</button> <button class="tab" data-screen="edit">View & edit</button> </nav> <section id="screen-edit" hidden> <fieldset> <legend>Load data</legend> <label for="eSerial">Spool serial</label> <input id="eSerial" placeholder="50017-FYG5" autocomplete="off"> <div class="row"> <div> <label for="eTagType">Tag type</label> <select id="eTagType"> <option>NTAG213</option> <option selected>NTAG215</option> <option>NTAG216</option> </select> </div> <div> <label for="eSpec">Spec version</label> <select id="eSpec"> <option value="2.001" selected>2.001</option> <option value="2.000">2.000</option> <option value="1.003">1.003</option> </select> </div> <div> <label for="eMode">Mode</label> <select id="eMode"> <option value="">Default for tag type</option> <option value="Core">Core</option> <option value="Extended">Extended</option> </select> </div> <div> <label for="eReader">Reader (blank = first ACR122)</label> <input id="eReader" placeholder="ACR122" autocomplete="off"> </div> </div> <div class="actions"> <button class="primary" id="loadSerial">Load from serial</button> <button id="loadTag">Load from tag</button> <button id="loadNew">New tag</button> </div> <p class="note" id="editNote"></p> </fieldset> <fieldset> <legend>Vendor profiles</legend> <div class="prow"> <div> <label for="eProfile">Saved profile</label> <select id="eProfile"><option value="">No profiles saved</option></select> </div> <button id="profileLoad" type="button">Load</button> <button id="profileDelete" type="button">Delete</button> </div> <div class="prow" style="margin-top:.6rem"> <div> <label for="eProfileName">Save current values as</label> <input id="eProfileName" placeholder="acme-pla-matte" autocomplete="off"> </div> <button id="profileSave" type="button">Save profile</button> </div> <p class="note">A profile stores the field values only — load one, then adjust the batch details before writing.</p> </fieldset> <form id="editForm" hidden autocomplete="off"> <fieldset> <legend id="editLegend">Tag data</legend> <div id="editFields"></div> <label for="eOutputDir">Output folder (blank = __DEFAULTDIR__)</label> <input id="eOutputDir" placeholder="__DEFAULTDIR__" autocomplete="off"> <div class="actions"> <button class="primary" id="applySave" type="button">Save image</button> <button id="applyWrite" type="button">Write to tag</button> <button id="revert" type="button">Revert</button> </div> </fieldset> </form> <div id="confirm"> <h3 id="confirmTitle"></h3> <div id="confirmBody"></div> <div class="actions"> <button class="primary" id="confirmGo" type="button">Continue anyway</button> <button id="confirmCancel" type="button">Cancel</button> </div> </div> <div id="editOut"></div> </section> <section id="screen-main"> <fieldset> <legend>Spool</legend> <label for="serial">Serial</label> <input id="serial" placeholder="50017-FYG5" autofocus autocomplete="off"> <div class="row"> <div> <label for="tagType">Tag type</label> <select id="tagType"> <option>NTAG213</option> <option selected>NTAG215</option> <option>NTAG216</option> </select> </div> <div> <label for="mode">Mode</label> <select id="mode"> <option value="">Default for tag type</option> <option value="Core">Core</option> <option value="Extended">Extended</option> </select> </div> <div> <label for="format">Format</label> <select id="format"> <option value="Ndef" selected>NDEF</option> <option value="Raw">Raw</option> </select> </div> </div> <label for="spec">Spec version</label> <select id="spec"> <option value="" selected>As served by the lookup</option> <option value="1.003">1.003</option> <option value="2.000">2.000</option> <option value="2.001">2.001</option> </select> <p class="note" id="specNote"></p> </fieldset> <fieldset> <legend>Destination</legend> <label for="outputDir">Output folder (blank = __DEFAULTDIR__)</label> <input id="outputDir" placeholder="__DEFAULTDIR__" autocomplete="off"> <label for="readerName">Reader name, for writing (blank = first ACR122)</label> <input id="readerName" placeholder="ACR122" autocomplete="off"> <div class="actions"> <button class="primary" id="save">Save image</button> <button id="write">Write tag</button> <button id="read">Read tag</button> <button id="stop">Stop server</button> </div> <p class="note" id="writeNote"></p> </fieldset> <div id="out"></div> <div id="tag"></div> </section> <script> const CAN_WRITE = __CANWRITE__; const $ = id => document.getElementById(id); const out = $('out'); if (!CAN_WRITE) { $('write').disabled = true; $('read').disabled = true; $('writeNote').textContent = 'Tag reading and writing unavailable: __PCSCERROR__ Saving an image still works.'; } function payload() { return { serial: $('serial').value.trim(), tagType: $('tagType').value, mode: $('mode').value, format: $('format').value, outputDir: $('outputDir').value.trim(), readerName: $('readerName').value.trim(), specVersion: $('spec').value }; } function show(ok, msg) { out.style.display = 'block'; out.className = ok ? 'ok' : 'err'; out.textContent = msg; } const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); function renderTag(data) { const t = $('tag'); const fields = data.fields || []; // Headings come from the server, because they depend on the tag's spec version. Any // section not in that list is still shown, appended in the order it first appears - // a row must never be dropped just because the page did not expect its heading. const order = (data.groups || []).slice(); for (const f of fields) if (!order.includes(f.section)) order.push(f.section); let html = '<h2>'; if (data.color) html += '<span class="swatch" style="background:' + esc(data.color.split(' ')[0]) + '"></span>'; html += esc(data.title || 'Tag contents'); if (data.specVersion) html += ' <span class="ver">OpenTag3D ' + esc(data.specVersion) + '</span>'; html += '</h2><table>'; for (const sec of order) { const rows = fields.filter(f => f.section === sec); if (!rows.length) continue; html += '<tr><th colspan="2">' + esc(sec) + '</th></tr>'; for (const f of rows) { html += '<tr><td class="k">' + esc(f.name) + '</td><td>' + esc(f.value) + '</td></tr>'; } } html += '</table><p class="note">' + fields.length + ' field(s) read.</p>'; t.innerHTML = html; t.style.display = 'block'; } async function run(action, btn) { const buttons = [$('save'), $('write'), $('read')]; buttons.forEach(b => b.disabled = true); show(true, 'Working...'); try { const res = await fetch('/api/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload()) }); const data = await res.json(); if (action === 'read' && data.ok) { out.style.display = 'none'; renderTag(data); } else { $('tag').style.display = 'none'; show(data.ok, data.message); } } catch (e) { show(false, 'Request failed: ' + e.message); } finally { buttons.forEach(b => b.disabled = false); if (!CAN_WRITE) { $('write').disabled = true; $('read').disabled = true; } } } // Heartbeat: the server stops when this stops arriving, so closing the tab shuts it down. const beat = () => fetch('/api/ping', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' }).catch(() => {}); beat(); setInterval(beat, 3000); // Fires on close AND on reload; the server waits a moment for a reloaded page to resume // the heartbeat before acting on it. addEventListener('pagehide', () => { navigator.sendBeacon('/api/bye', new Blob(['{}'], { type:'application/json' })); }); $('save').onclick = () => run('export'); $('write').onclick = () => run('write'); $('read').onclick = () => run('read'); $('serial').addEventListener('keydown', e => { if (e.key === 'Enter') run('export'); }); // ---- spec version ---- // OpenTag3D 2.x dropped NTAG213 (216 bytes of payload against 144 bytes of user memory), // so the chip is taken out of the list rather than left there to fail on write. It comes // back when 1.003 is selected. Mode is a 1.003 concept too - 2.x is one flat block. function applySpec(specSel, typeSel, modeSel, note) { const v = specSel.value; const v2 = v.charAt(0) === '2'; const had = typeSel.value; if (v2) { const opt = [...typeSel.options].find(o => (o.value || o.textContent) === 'NTAG213'); if (opt) opt.remove(); if (had === 'NTAG213') typeSel.value = 'NTAG215'; } else if (![...typeSel.options].some(o => (o.value || o.textContent) === 'NTAG213')) { const opt = document.createElement('option'); opt.textContent = 'NTAG213'; typeSel.insertBefore(opt, typeSel.firstChild); } if (modeSel) { modeSel.disabled = v2; modeSel.title = v2 ? 'OpenTag3D 2.x is one flat block - Core and Extended are 1.003 only' : ''; } if (note) { note.textContent = v2 ? 'OpenTag3D ' + v + ': one 216-byte block, four extra fields (SKU, barcode, nozzle, chamber temp), NTAG215 or NTAG216 only.' : (v === '1.003' ? 'OpenTag3D 1.003: Core 112 bytes, Extended 187. Fits any NTAG21x.' : 'Whatever the lookup service returns is written unchanged.'); } } const eSpecSync = () => applySpec($('eSpec'), $('eTagType'), $('eMode'), null); const mSpecSync = () => applySpec($('spec'), $('tagType'), $('mode'), $('specNote')); $('eSpec').onchange = eSpecSync; $('spec').onchange = mSpecSync; eSpecSync(); mSpecSync(); // ---- tabs ---- document.querySelectorAll('.tab').forEach(t => { t.onclick = () => { document.querySelectorAll('.tab').forEach(x => x.classList.toggle('active', x === t)); $('screen-main').hidden = t.dataset.screen !== 'main'; $('screen-edit').hidden = t.dataset.screen !== 'edit'; }; }); // ---- edit screen ---- let loaded = null; // { fields, payloadHex } if (!CAN_WRITE) { $('loadTag').disabled = true; $('applyWrite').disabled = true; $('editNote').textContent = 'Tag access unavailable: __PCSCERROR__ Loading from a serial still works.'; } function eshow(ok, msg) { const o = $('editOut'); o.style.display = 'block'; o.className = ok ? 'ok' : 'err'; o.textContent = msg; } // A colour field is '#RRGGBB', optionally with ' (alpha N)'. The text box stays the // value of record - it is what gets collected and posted - and the picker writes into // it. An empty box means the colour is unused, which the spec stores as transparent // black, so the picker starts neutral and only fills the box once you choose something. function colourRow(row, inp, readonly) { const pick = document.createElement('input'); pick.type = 'color'; pick.tabIndex = readonly ? -1 : 0; pick.disabled = !!readonly; pick.title = 'Pick a colour'; const clear = document.createElement('button'); clear.type = 'button'; clear.className = 'clr'; clear.textContent = 'clear'; clear.title = 'Leave this colour unused'; clear.disabled = !!readonly; const hexOf = v => { const m = /#?([0-9A-Fa-f]{6})/.exec(v || ''); return m ? '#' + m[1].toUpperCase() : null; }; const alphaOf = v => { const m = /\(alpha\s*(\d+)\)/i.exec(v || ''); return m ? ' (alpha ' + m[1] + ')' : ''; }; const sync = () => { const hex = hexOf(inp.value); pick.value = hex || '#000000'; pick.classList.toggle('unset', !hex); }; pick.addEventListener('input', () => { inp.value = pick.value.toUpperCase() + alphaOf(inp.value); pick.classList.remove('unset'); }); inp.addEventListener('input', sync); clear.addEventListener('click', () => { inp.value = ''; sync(); }); sync(); row.appendChild(pick); row.appendChild(clear); } function renderEditor(data) { loaded = data; // The payload decides the version, not the dropdown: loading a tag or a lookup can hand // back the other one, and the form must follow the bytes it is editing. if (data.specVersion && $('eSpec').value !== data.specVersion) { $('eSpec').value = data.specVersion; eSpecSync(); } $('editLegend').textContent = (data.title || 'Tag data') + (data.specVersion ? ' \u00b7 OpenTag3D ' + data.specVersion : ''); const host = $('editFields'); host.innerHTML = ''; let section = null; for (const f of data.fields) { if (f.section !== section) { section = f.section; const h = document.createElement('div'); h.className = 'fgroup'; h.textContent = section; host.appendChild(h); } const row = document.createElement('div'); row.className = 'frow'; const lab = document.createElement('label'); lab.textContent = f.name + (f.unit ? ' (' + f.unit + ')' : ''); lab.htmlFor = 'f_' + f.id; const inp = document.createElement('input'); inp.id = 'f_' + f.id; inp.dataset.fid = f.id; inp.value = f.value; if (f.readonly) { inp.readOnly = true; inp.tabIndex = -1; } row.appendChild(lab); row.appendChild(inp); if (f.type === 'rgba') colourRow(row, inp, f.readonly); host.appendChild(row); } $('editForm').hidden = false; $('confirm').style.display = 'none'; eshow(true, (data.note ? data.note + ' ' : '') + 'Loaded. Edit any field, then save or write.'); } function collect() { const values = {}; document.querySelectorAll('#editFields input[data-fid]').forEach(i => { values[i.dataset.fid] = i.value; }); return values; } async function post(url, body) { const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); return res.json(); } async function load(source) { const btns = [$('loadSerial'), $('loadTag'), $('loadNew'), $('profileLoad')]; btns.forEach(b => b.disabled = true); eshow(true, 'Loading...'); try { const data = await post('/api/load', { source, serial: $('eSerial').value.trim(), tagType: $('eTagType').value, mode: $('eMode').value, specVersion: $('eSpec').value, readerName: $('eReader').value.trim(), profileName: $('eProfile').value }); if (data.ok) renderEditor(data); else { $('editForm').hidden = true; eshow(false, data.message); } } catch (e) { eshow(false, 'Request failed: ' + e.message); } finally { btns.forEach(b => b.disabled = false); if (!CAN_WRITE) $('loadTag').disabled = true; } } // ---- vendor profiles ---- function fillProfiles(names, select) { const sel = $('eProfile'); const keep = select || sel.value; sel.innerHTML = ''; if (!names || !names.length) { sel.innerHTML = '<option value="">No profiles saved</option>'; return; } for (const n of names) { const o = document.createElement('option'); o.value = n; o.textContent = n; sel.appendChild(o); } if (keep && names.includes(keep)) sel.value = keep; } async function profile(op, extra) { const btns = [$('profileLoad'), $('profileSave'), $('profileDelete')]; btns.forEach(b => b.disabled = true); try { const data = await post('/api/profile', Object.assign({ op }, extra || {})); if (data.profiles) fillProfiles(data.profiles, data.name); if (op !== 'list') eshow(data.ok, data.message); return data; } catch (e) { if (op !== 'list') eshow(false, 'Request failed: ' + e.message); } finally { btns.forEach(b => b.disabled = false); } } // Shared by both confirmations: title, body, button label, then wire the buttons. function showConfirm(title, bodyHtml, goLabel, onYes) { $('confirmTitle').textContent = title; $('confirmBody').innerHTML = bodyHtml; $('confirmGo').textContent = goLabel; $('confirm').style.display = 'block'; $('editOut').style.display = 'none'; $('confirmGo').onclick = () => { $('confirm').style.display = 'none'; onYes(); }; $('confirmCancel').onclick = () => { $('confirm').style.display = 'none'; eshow(true, 'Cancelled. Nothing was saved or written.'); }; } function askMigrate(data, onYes) { const lost = (data.lost || []); const why = (data.why || ''); let body = '<div style="font-size:.9rem">' + esc(why.charAt(0).toUpperCase() + why.slice(1)) + '.</div>'; if (lost.length) { body += '<ul>' + lost.map(f => '<li>' + esc(f) + '</li>').join('') + '</ul>' + '<div style="font-size:.85rem;opacity:.75">Those fields have no address in ' + esc(data.imageVersion) + ', so whatever the tag holds in them is dropped.</div>'; } else { body += '<div style="font-size:.85rem;opacity:.75;margin-top:.5rem">The data carries over ' + 'unchanged; only the layout it is written in changes.</div>'; } body += '<div style="font-size:.85rem;opacity:.75;margin-top:.5rem">To keep the spool on ' + esc(data.tagVersion) + ' instead, cancel and set the spec version to ' + esc(data.tagVersion) + ' before writing.</div>'; showConfirm(data.message, body, 'Migrate to ' + data.imageVersion, onYes); } function askConfirm(data, onYes) { $('confirmTitle').textContent = data.message; $('confirmBody').innerHTML = '<ul>' + (data.keeping || []).map(f => '<li>' + f.name + (f.value ? ': ' + f.value : '') + '</li>').join('') + '</ul>' + '<div style="font-size:.85rem;opacity:.75">' + (data.dropped || 0) + ' extended field(s) will be discarded. To keep everything, choose NTAG215 or NTAG216.</div>'; $('confirmGo').textContent = 'Continue anyway'; $('confirm').style.display = 'block'; $('editOut').style.display = 'none'; $('confirmGo').onclick = () => { $('confirm').style.display = 'none'; onYes(); }; $('confirmCancel').onclick = () => { $('confirm').style.display = 'none'; eshow(true, 'Cancelled. Nothing was saved or written.'); }; } async function apply(target, confirmTruncate, confirmMigrate) { if (!loaded) return; const btns = [$('applySave'), $('applyWrite'), $('revert')]; btns.forEach(b => b.disabled = true); $('confirm').style.display = 'none'; eshow(true, target === 'tag' ? 'Writing...' : 'Saving...'); try { const data = await post('/api/apply', { target, tagType: $('eTagType').value, payloadHex: loaded.payloadHex, values: collect(), outputDir: $('eOutputDir').value.trim(), readerName: $('eReader').value.trim(), generic: !!loaded.generic, confirmTruncate: !!confirmTruncate, confirmMigrate: !!confirmMigrate }); if (data.confirm === 'truncate') { askConfirm(data, () => apply(target, true, confirmMigrate)); } else if (data.confirm === 'version') { askMigrate(data, () => apply(target, confirmTruncate, true)); } else { eshow(data.ok, data.message); } } catch (e) { eshow(false, 'Request failed: ' + e.message); } finally { btns.forEach(b => b.disabled = false); if (!CAN_WRITE) $('applyWrite').disabled = true; } } $('loadSerial').onclick = () => load('serial'); $('loadTag').onclick = () => load('tag'); $('loadNew').onclick = () => load('new'); $('profileLoad').onclick = () => { if (!$('eProfile').value) { eshow(false, 'No profile selected.'); return; } load('profile'); }; $('profileSave').onclick = () => { const name = $('eProfileName').value.trim(); if (!name) { eshow(false, 'Name the profile first.'); return; } if (!loaded) { eshow(false, 'Load or start a tag before saving a profile.'); return; } profile('save', { name, values: collect(), tagType: $('eTagType').value, mode: $('eMode').value || loaded.mode, specVersion: loaded.specVersion || $('eSpec').value }); }; $('profileDelete').onclick = () => { const name = $('eProfile').value; if (!name) { eshow(false, 'No profile selected.'); return; } profile('delete', { name }); }; profile('list'); $('applySave').onclick = () => apply('file', false); $('applyWrite').onclick = () => apply('tag', false); $('revert').onclick = () => { if (loaded) renderEditor(loaded); }; $('eSerial').addEventListener('keydown', e => { if (e.key === 'Enter') load('serial'); }); $('stop').onclick = async () => { await fetch('/api/stop', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' }).catch(() => {}); document.body.innerHTML = '<h1>Stopped</h1><p class="sub">You can close this tab.</p>'; }; </script> '@ $reason = if ($PcscError) { $PcscError } else { 'No PC/SC reader detected.' } $page.Replace('__CANWRITE__', $CanWrite.ToString().ToLowerInvariant()). Replace('__DEFAULTDIR__', [Net.WebUtility]::HtmlEncode($DefaultDir)). Replace('__PCSCERROR__', [Net.WebUtility]::HtmlEncode($reason)) } |