cryptnox-pos 1.0.0
Standalone USDC payment terminal firmware (ESP32 + Cryptnox smart card)
Loading...
Searching...
No Matches
portal_page.h
Go to the documentation of this file.
1/*
2 * SPDX-License-Identifier: LGPL-3.0-or-later
3 * Copyright (c) 2026 Cryptnox SA
4 */
5
21
22#ifndef PORTAL_PAGE_H
23#define PORTAL_PAGE_H
24
25#include "card_front.h" /* CARD_FRONT_PNG_URI — the portal's card picture */
26#include "portal_fonts.h" /* PORTAL_FONTS_CSS — the faces, tools/gen_portal_fonts.py */
27
28/* The AP's own address, and therefore the answer to every DNS question the
29 * portal is asked. esp_netif's SoftAP default; changing it means changing this
30 * string. Defined here because the page below prints the URL for an operator to
31 * type by hand, and provision.cpp redirects to the same one — one definition, so
32 * what is shown and what is served cannot disagree. */
33#define PORTAL_IP "192.168.4.1"
34#define PORTAL_URL "http://" PORTAL_IP "/"
35
36/* One document for both modes and every step, with each section hidden until
37 * /api/state says it applies. Rendering by toggling `hidden` rather than by
38 * building DOM keeps the JavaScript to something a reviewer can read, and means
39 * the wizard and the admin page cannot drift apart into two designs. */
40static const char *const PAGE_HTML =
41"<!doctype html><html lang=en><head><meta charset=utf-8>"
42"<meta name=viewport content='width=device-width,initial-scale=1'>"
43"<title>Cryptnox POS</title><style>"
45
46/* The Cryptnox palette, taken from the brand's own stylesheet
47 * (cryptnox.github.io/docs/source/_static/custom.css): apricot #fcb770 as the
48 * accent, slate #2c3e50 as the ink, #e1e4e5 hairlines. Plus a dark scheme,
49 * because this is opened on a phone in a venue and a white page at night is the
50 * first thing an operator complains about. Custom properties rather than two
51 * stylesheets: the dark block only restates the colours that differ.
52 *
53 * --accf is slate and NOT white on purpose. #fcb770 is a light accent: white on
54 * it is 1.8:1 and unreadable, slate on it is 6.3:1 and passes AA. Anything put on
55 * the accent from here on uses --accf. */
56":root{color-scheme:light dark;accent-color:var(--acc);"
57"--bg:#f4f6f8;--card:#fff;--fg:#2c3e50;--dim:#5a6874;--line:#e1e4e5;"
58"--soft:#f8f9fa;--acc:#fcb770;--accf:#2c3e50;--ink:#2c3e50;"
59"--tint:#fdf3e8;--tintl:#f2d3ac;--tintf:#8a5a1c;"
60"--okbg:#eaf6ef;--okl:#b8e0c8;--okf:#186c39;"
61"--errbg:#f8d7da;--errl:#f5c6cb;--errf:#721c24;"
62"--sh:0 1px 2px rgba(44,62,80,.05),0 1px 8px rgba(44,62,80,.04)}"
63"@media(prefers-color-scheme:dark){:root{"
64"--bg:#131a21;--card:#1b242e;--fg:#eaeff4;--dim:#93a2b1;--line:#2a3540;"
65"--soft:#222d38;--acc:#fcb770;--accf:#22303d;--ink:#0e141a;"
66"--tint:#2a2318;--tintl:#4d3d26;--tintf:#fcb770;"
67"--okbg:#16281e;--okl:#27492f;--okf:#7fd0a0;"
68"--errbg:#2c1619;--errl:#5b2b30;--errf:#f2a2aa;"
69"--sh:0 1px 2px rgba(0,0,0,.3)}}"
70
71"*{box-sizing:border-box}"
72/* Sections are toggled by the `hidden` attribute and some of them are flex or
73 * grid, which outranks the browser's default `[hidden]{display:none}`. Without
74 * this line every section shows at once. */
75"[hidden]{display:none!important}"
76/* The terminal's own faces: Inter for everything read, Plus Jakarta Sans
77 * (--head) for the brand, headings and buttons — the split the panel uses. Both
78 * are embedded above, because the phone is on a captive portal with no route to
79 * a font CDN; the system stack is only what shows while they decode. Weights
80 * are the embedded ones only (Inter 400/500/600, Jakarta 600) — asking for
81 * another gets a faux bold, which is why <b> is pinned to 600. */
82":root{--head:'Plus Jakarta Sans',Inter,-apple-system,'Segoe UI',system-ui,sans-serif}"
83"b,strong{font-weight:600}"
84"body{margin:0;padding:0 16px 40px;background:var(--bg);color:var(--fg);"
85"font:16px/1.55 Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',"
86"system-ui,sans-serif;-webkit-font-smoothing:antialiased}"
87"header,main{max-width:34rem;margin:0 auto}"
88"header{display:flex;align-items:center;gap:10px;padding:22px 2px 16px}"
89".mark{flex:0 0 30px;width:30px;height:30px;display:block}"
90".brand{font-family:var(--head);font-size:1.05rem;font-weight:600;letter-spacing:-.015em;margin:0}"
91".chip{margin-left:auto;padding:6px 10px;border-radius:999px;font-size:.75rem;"
92"color:var(--dim);background:var(--card);border:1px solid var(--line);font-weight:500}"
93
94"section{background:var(--card);border:1px solid var(--line);border-radius:16px;"
95"padding:18px;margin:0 0 14px;box-shadow:var(--sh)}"
96/* The apricot tick beside a heading is the only decoration on the page. It is
97 * what makes a stack of grey cards read as Cryptnox rather than as a default
98 * form, and it costs one pseudo-element. */
99"h2{font-family:var(--head);font-size:.95rem;font-weight:600;margin:0 0 .35rem;padding-left:12px;"
100"position:relative;letter-spacing:-.005em}"
101"h2::before{content:'';position:absolute;left:0;top:.28em;width:3px;"
102"height:.95em;border-radius:2px;background:var(--acc)}"
103/* A second heading inside one card ("Or type them") is a divider, not a new card. */
104"section h2~h2{margin-top:1.6rem;padding-top:1.2rem;border-top:1px solid var(--line)}"
105"p,label{display:block;color:var(--dim);font-size:.92rem;margin:.3rem 0 .9rem}"
106/* A label belongs to the field under it, so it keeps only the field's own 6px. */
107"label{margin-bottom:0;font-weight:500}"
108
109"input,select,button{font:inherit;width:100%;padding:12px 14px;margin:6px 0 0;"
110"border:1px solid var(--line);border-radius:11px;background:var(--soft);"
111"color:var(--fg)}"
112/* The six address fields are the inputs with no type= — the other two are the
113 * Wi-Fi password and the file picker. Addresses are compared character by
114 * character against a panel, so they get a face where 0 and O differ. */
115"input:not([type]){font-family:ui-monospace,SFMono-Regular,Menlo,monospace;"
116"font-size:.9rem}"
117"input::placeholder{color:var(--dim)}"
118/* Radio rows. The shared rule above stretches every control to the card width,
119 * which on a phone turns each radio into a full-width box with its own label
120 * stranded on the line below it — two options that read as four things. The row
121 * is the target instead: the label is the box, the radio keeps its intrinsic
122 * size beside the text, and a name too long for the width wraps under itself
123 * rather than under the button. */
124".opt{display:flex;align-items:center;gap:12px;min-height:48px;"
125"margin:8px 0 0;padding:10px 14px;border:1px solid var(--line);"
126"border-radius:11px;background:var(--soft);color:var(--fg);cursor:pointer}"
127"input[type=radio]{width:auto;flex:none;margin:0;padding:0;"
128"accent-color:var(--acc)}"
129/* The networks under each name, not trailing off the end of it. */
130".opt small{display:block;margin-top:1px}"
131/* Provenance under a value — "built into this firmware" and the like. Grey and a
132 * size down, so the address above it stays the thing being read. */
133"small{display:inline-block;margin-top:3px;color:var(--dim);font-size:.82rem}"
134":focus-visible{outline:2px solid var(--acc);outline-offset:2px}"
135"input[type=file]{padding:10px}"
136"input[type=file]::file-selector-button{font:inherit;margin-right:10px;"
137"padding:7px 12px;border:1px solid var(--line);border-radius:8px;"
138"background:var(--card);color:var(--fg)}"
139
140/* The file input is driven by the Browse button beside it, so it is taken out of
141 * the flow rather than removed from it. It used to carry `hidden`, and a
142 * display:none input is one iOS Safari will not open from a scripted .click() —
143 * the button did nothing at all on a phone, which is the one device this page is
144 * designed to be used from. Present, laid out, and invisible: clip-path over
145 * width/height:0 so the element still has a box for the picker to hang off. */
146".sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;border:0;"
147"overflow:hidden;clip-path:inset(50%);white-space:nowrap}"
148
149/* 48px so it is a thumb target, not a mouse one. */
150"button{min-height:48px;margin-top:10px;border:0;background:var(--acc);"
151"color:var(--accf);font-family:var(--head);font-weight:600;cursor:pointer;"
152"transition:filter .15s,transform .05s}"
153"button:hover{filter:brightness(1.06)}button:active{transform:scale(.995)}"
154"button[disabled]{opacity:.45;cursor:not-allowed;filter:none}"
155"button.alt{background:var(--card);color:var(--fg);border:1px solid var(--line)}"
156"button.alt:hover{border-color:var(--acc);filter:none}"
157
158/* The reveal eye sits inside the password box. The wrapper carries the field's
159 * margin so the button can be centred on the input itself, and the input keeps
160 * its text clear of the button. */
161".pw{position:relative;margin:6px 0 0}.pw input{margin:0;padding-right:52px}"
162".eye{position:absolute;right:5px;top:50%;transform:translateY(-50%);"
163"width:42px;height:38px;min-height:0;margin:0;padding:0;background:none;"
164"border:0;color:var(--dim)}"
165".eye:hover{filter:none;color:var(--fg)}"
166".eye[aria-pressed=true]{color:var(--fg)}"
167".eye svg{display:block;width:22px;height:22px;margin:0 auto;fill:none;"
168"stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}"
169/* Which of the two is drawn is derived from aria-pressed rather than toggled in
170 * JavaScript, so the state a screen reader is told and the state the icon shows
171 * cannot drift apart — and .hidden does not exist on an SVG element anyway.
172 * Masked shows the plain eye ("reveal"); revealed shows the struck-through one. */
173".eye .off,.eye[aria-pressed=true] .on{display:none}"
174".eye[aria-pressed=true] .off{display:block}"
175
176"code{font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;"
177"background:var(--soft);border:1px solid var(--line);padding:3px 7px;"
178"border-radius:7px;word-break:break-all;color:var(--fg)}"
179"pre{background:var(--soft);border:1px solid var(--line);padding:12px;"
180"border-radius:11px;white-space:pre-wrap;color:var(--dim);font-size:.85rem}"
181
182/* :empty rather than a JS toggle — render() already writes '' when there is
183 * nothing to say, so the banner collapses on its own. */
184"#msg,#note{margin:0 0 14px;padding:12px 14px 12px 16px;border-radius:12px;"
185"background:var(--card);border:1px solid var(--line);border-left:3px solid "
186"var(--acc);color:var(--fg);box-shadow:var(--sh)}"
187"#msg:empty,#note:empty{display:none}"
188/* Severity, Bootstrap's alert colours in this palette's terms: a refused address
189 * and a stored one used to be the same grey box with the same accent bar, which
190 * is how an operator walks away from a rejection thinking it went in. Red or
191 * green, and sticky — the message is at the top of the document and the button
192 * that produced it may be a scroll away. */
193"#msg{position:sticky;top:8px;z-index:5}"
194/* Written with the id and not as a bare .err, which would lose to the
195 * id-carrying base rule above it and repaint nothing at all. */
196"#msg.err{background:var(--errbg);border-color:var(--errl);"
197"border-left-color:var(--errf);color:var(--errf);font-weight:600}"
198"#msg.ok{background:var(--okbg);border-color:var(--okl);"
199"border-left-color:var(--okf);color:var(--okf)}"
200/* The device's own line, and it is only ever written when something did not go
201 * the way it was asked, so it wears the warning tint outright. */
202"#note{background:var(--tint);border-color:var(--tintl);color:var(--tintf)}"
203/* The card illustration — the real card front, inlined as a data URI so it costs
204 * no request on a captive portal. Beside its paragraph rather than above it: the
205 * picture is only there to say "this object", so it earns a thumbnail's worth of
206 * a phone screen, not a banner's. The 1px halo traces the card's own rounded
207 * outline (drop-shadow follows the alpha, not the box), which a black card needs
208 * to read as an object on the dark scheme's near-black background. */
209".cardrow{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;"
210"gap:.9rem;margin:.3rem 0 .9rem}"
211".cardart{flex:0 1 15rem;width:15rem;max-width:100%;height:auto;"
212"filter:drop-shadow(0 0 1px var(--dim))}"
213/* The wrap is the whole narrow-screen story, no media query: a phone cannot fit
214 * 15rem of card and 11rem of prose on one line, so the paragraph drops below the
215 * picture — which is where it sat before it moved beside it. */
216".cardrow p{flex:1 1 11rem;margin:0}"
217
218".wait{display:flex;align-items:center;gap:12px;padding:14px 16px;"
219"border-radius:12px;background:var(--tint);border:1px solid var(--tintl);"
220"color:var(--tintf);font-size:.92rem}"
221/* The same tinted box holding a paragraph instead of a spinner and a line. Flex
222 * makes every run of text its own item, so "Browse does nothing?" and the
223 * sentence explaining it were laid out as two columns and squeezed against each
224 * other on a phone — which is the width this particular note is always read at. */
225".wait.prose{display:block}"
226/* The pairing line under "Waiting for the admin code", not squeezed beside it.
227 * A box's bold line gets a zero basis so it stays beside the spinner: with
228 * wrapping on, a full-width line wraps under it instead of shrinking. */
229".wait{flex-wrap:wrap}.wait b{flex:1 1 0}#pair{flex-basis:100%;margin:0}"
230".spin{flex:0 0 18px;width:18px;height:18px;border:2px solid currentColor;"
231"border-top-color:transparent;border-radius:50%;animation:sp .8s linear infinite}"
232"@keyframes sp{to{transform:rotate(360deg)}}"
233"@media(prefers-reduced-motion:reduce){.spin{animation:none}}"
234
235/* The one card that is a demand rather than a form, so it wears the accent
236 * outright instead of a hairline. */
237"#s_pend{background:var(--tint);border-color:var(--tintl);"
238"border-left:3px solid var(--acc)}"
239"#s_pend h2,#s_pend p{color:var(--tintf)}"
240"#s_pend h2::before{display:none}#s_pend h2{padding-left:0}"
241"#pend{font-weight:600;word-break:break-all}"
242/* A chosen .bin's name, straight off the operator's filesystem: it has no spaces
243 * to break at, so without this a long one widens the card and takes the page's
244 * horizontal scrollbar with it. */
245"#fwfile{word-break:break-all}"
246/* Finishing is the one green moment in the flow; ui.cpp's COL_SUCCESS. */
247"#s_final{background:var(--okbg);border-color:var(--okl)}"
248"#s_final h2,#s_final p{color:var(--okf)}"
249"#s_final h2::before{background:var(--okf)}"
250"#nav{background:none;border:0;padding:0;box-shadow:none}"
251"</style></head><body>"
252
253/* The Cryptnox mark from assets/logo.svg, inlined so it needs no request — the
254 * phone is on a captive portal and a second GET for a logo is a second thing
255 * that can hang. The C is white as in assets/logo.svg, not an accent that follows
256 * the scheme; the disc behind it is --ink, dark in both schemes, so it stays legible.
257 *
258 * A <symbol> and a <use> rather than the path inline in the header: it was drawn
259 * twice when the card illustration was line art too, and the indirection is kept
260 * because it costs nothing and the next place that wants the mark is free. The
261 * fills are inline styles and not a `.mark path{}` rule because <use> clones into
262 * a shadow tree that outer selectors cannot reach; custom properties still
263 * inherit into it, so var() resolves and the scheme still applies. */
264"<svg width=0 height=0 aria-hidden=true style=position:absolute>"
265"<symbol id=cnx viewBox='0 0 461 461'>"
266"<circle cx=230.5 cy=230.5 r=230.5 style='fill:var(--ink)'/>"
267"<path style=fill:#fff d='M229.02 406C205.904 406 183.016 401.434 161.66 392.565C140.304 383.694 "
268"120.9 370.694 104.555 354.304C88.2102 337.914 75.2443 318.457 66.3988 297.044C57.5533 "
269"275.629 53 252.678 53 229.5C53 206.322 57.5533 183.371 66.3988 161.956C75.2443 140.542 "
270"88.2102 121.086 104.555 104.696C120.9 88.3063 140.304 75.305 161.66 66.4354C183.016 "
271"57.5657 205.904 53 229.02 53C274.665 53 315.69 68.9362 347.641 99.0661L352.575 "
272"103.828L340.286 117.652L334.964 112.575C306.523 85.724 269.878 71.5302 229.02 "
273"71.5302C187.237 71.5302 147.167 88.1732 117.622 117.798C88.0774 147.424 71.4798 "
274"187.604 71.4798 229.5C71.4798 271.396 88.0774 311.576 117.622 341.202C147.167 370.826 "
275"187.237 387.47 229.02 387.47C271.523 387.47 305.47 370.144 328.108 353.689L300.499 "
276"312.033C272.946 329.859 254.06 336.122 229.02 336.122C200.838 336.122 173.811 324.897 "
277"153.883 304.915C133.956 284.933 122.761 257.832 122.761 229.574C122.761 201.315 133.956 "
278"174.215 153.883 154.233C173.811 134.251 200.838 123.026 229.02 123.026C252.519 122.992 "
279"275.419 130.463 294.401 144.354L305.1 131.382C283.171 114.798 256.487 105.758 229.02 "
280"105.607C196.241 105.607 164.804 118.664 141.626 141.906C118.448 165.147 105.427 196.669 "
281"105.427 229.537C105.427 262.405 118.448 293.927 141.626 317.169C164.804 340.41 196.241 "
282"353.466 229.02 353.466C249.711 353.777 270.114 348.586 288.155 338.42L295.122 "
283"334.492L305.286 350.039L297.228 354.578C276.44 366.31 252.927 372.32 229.075 "
284"371.997C191.395 371.997 155.258 356.987 128.614 330.272C101.971 303.555 87.003 267.32 "
285"87.003 229.537C87.003 191.754 101.971 155.519 128.614 128.803C155.258 102.086 191.395 "
286"87.0768 229.075 87.0768C264.098 87.2915 297.879 100.115 324.264 123.21L331.009 "
287"129.159L296.618 170.723L289.467 164.237C272.761 149.522 251.255 141.459 229.02 "
288"141.574C205.739 141.574 183.412 150.848 166.95 167.354C150.489 183.861 141.241 206.249 "
289"141.241 229.592C141.241 252.937 150.489 275.324 166.95 291.831C183.412 308.337 205.739 "
290"317.611 229.02 317.611C252.359 317.611 269.102 311.292 297.875 291.669L305.618 "
291"286.276L353 357.803L346.274 363.083C310.977 391.157 270.506 406 229.02 406Z'/>"
292"</symbol></svg>"
293"<header><svg class=mark aria-hidden=true><use href='#cnx'/></svg>"
294"<h1 class=brand>Cryptnox POS</h1>"
295"<span class=chip>fw <b id=ver>&hellip;</b></span></header>"
296"<main>"
297"<p id=msg role=alert></p>"
298"<p id=note role=status></p>"
299
300/* Authorisation. The only thing an unauthorised browser can see, and it does not
301 * ask for the code — it asks the operator to look at the terminal. */
302"<section id=s_auth hidden><h2>Authorize this browser</h2>"
303"<p>The admin code is never typed here. Enter it on the terminal's own screen "
304"&mdash; that is what proves you are standing in front of it.</p>"
305"<div id=waiting class=wait hidden><span class=spin></span>"
306"<b>Waiting for the admin code on the terminal screen&hellip;</b>"
307/* The pairing code: the panel's prompt reads "Browser NNNN" for the browser it
308 * is about to let in. Another phone on the access point asking first would show
309 * a different number there — the one thing that tells the operator not to type
310 * the code. */
311"<p id=pair></p></div>"
312"<button id=go_auth>Ask the terminal again</button></section>"
313
314/* Something is waiting to be accepted on the panel. Shown over everything else,
315 * because until it is resolved nothing else can be proposed. */
316"<section id=s_pend hidden><h2>Check the terminal screen</h2>"
317"<p><span id=pend></span> is on the terminal screen now. Compare it there, "
318"character by character, and accept it on the terminal.</p></section>"
319
320/* Production or test, for all three networks at once. Not per-network: "Ethereum
321 * mainnet with Tron on Nile" is not a configuration anybody wants, it is a
322 * terminal half of whose sales settle in nothing.
323 *
324 * Written straight through like the gas fees rather than proposed on the panel,
325 * and then the terminal restarts itself — the endpoints, chain ids and token
326 * contracts are resolved once at boot into the stores that are reconciled before
327 * every signature, and there is no honest way to swap those under a running
328 * payment. The restart is the mechanism, not an inconvenience around one.
329 *
330 * The stored token contracts are per-network, so switching does not carry a
331 * Sepolia address onto mainnet; the payout addresses are shared, because a card's
332 * address is the same account on either. */
333"<section id=s_net hidden><h2>Network</h2>"
334"<p>Currently <code id=cur_net>&hellip;</code>. Test networks move worthless "
335"tokens and are for trying the terminal out; production settles real money.</p>"
336"<label class=opt><input type=radio name=net value=main id=net_main> "
337"<span>Production <small>Ethereum, Polygon, Tron</small></span></label>"
338"<label class=opt><input type=radio name=net value=test id=net_test> "
339"<span>Test <small>Sepolia, Amoy, Nile</small></span></label>"
340"<p><b>The terminal restarts when this changes.</b> Check the asset on its Tx "
341"tab afterwards &mdash; a token you set by hand is stored per network, so the "
342"other one falls back to the firmware's own contract.</p>"
343"<button class=alt id=go_net>Switch network</button></section>"
344
345/* Nothing here needs doing on a working terminal, and the section has to open by
346 * saying so. It used to report a contract nobody had overridden as "not set",
347 * which beside a USDC selection that charges perfectly well reads as a fault —
348 * and the obvious repair for a fault is to paste something over an address that
349 * was already right. The terminal ships knowing these; this is the override. */
350"<section id=s_ct hidden><h2>Token contracts</h2>"
351"<p>Which contract the terminal calls for USDC and USDT. It already has one for "
352"each, built into the firmware &mdash; <b>leave these alone unless you know the "
353"deployment has moved</b>. A wrong contract moves a different asset, so a new one "
354"is accepted on the terminal screen like a payout address.</p>"
355"<p>ERC-20 on Ethereum &mdash; using <code id=cur_cte>&hellip;</code>"
356"<br><small id=src_cte></small></p>"
357"<input id=in_cte placeholder='0x...' autocapitalize=off autocomplete=off>"
358"<button class=alt id=go_cte>Propose ERC-20 contract</button>"
359"<p>TRC-20 on Tron &mdash; using <code id=cur_ctt>&hellip;</code>"
360"<br><small id=src_ctt></small></p>"
361"<input id=in_ctt placeholder='T...' autocapitalize=off autocomplete=off>"
362"<button class=alt id=go_ctt>Propose TRC-20 contract</button></section>"
363
364/* Gas caps. They used to be two +/- steppers on the terminal's Tx tab; they live
365 * here because every other stored setting does, and a fee typed on a keyboard beats
366 * forty taps on a resistive panel. Not proposed like an address either: a cap
367 * cannot send money anywhere, so the worst a wrong one does is price a sale out of
368 * a block, which the terminal reports the moment it tries. Tron is absent on
369 * purpose — a transfer there is paid in bandwidth and the token's energy cap is
370 * compile-time, so there is nothing to set. */
371"<section id=s_fee hidden><h2>Gas fees</h2>"
372"<p>What the terminal is willing to pay per unit of gas on Ethereum and Polygon, "
373"in Gwei. The tip is capped by the max fee, and applies to the next sale.</p>"
374"<label for=in_fmax>Max fee &mdash; currently "
375"<code id=cur_fmax>&hellip;</code></label>"
376"<input id=in_fmax type=number min=1 max=500 step=1 inputmode=numeric>"
377"<label for=in_fprio>Priority fee (tip) &mdash; currently "
378"<code id=cur_fprio>&hellip;</code></label>"
379"<input id=in_fprio type=number min=1 max=500 step=1 inputmode=numeric>"
380"<button class=alt id=go_fee>Save gas fees</button></section>"
381
382/* The panel clock's time zone. A list rather than a typed number because every
383 * wrong answer here is a plausible-looking one, and because the real set is not
384 * the round hours people expect — India is +5:30 and Nepal +5:45.
385 *
386 * Regions first: each is a standard offset plus one of the DST rules in
387 * civil_time.h, and the terminal changes the hour itself. Then every fixed
388 * offset, for anywhere whose rules the terminal does not know. */
389"<section id=s_clock hidden><h2>Clock</h2>"
390"<p>What the time in the corner of the terminal's screen reads in &mdash; "
391"currently <code id=cur_tz>&hellip;</code>. The terminal keeps UTC from the "
392"network; this is only what it adds before showing it.</p>"
393"<label for=in_tz>Time zone</label>"
394"<select id=in_tz></select>"
395"<p><small>A region follows its daylight-saving changes by itself. A fixed "
396"offset never changes, so where the clocks move you come back here twice a "
397"year.</small></p>"
398"<button class=alt id=go_clock>Save clock</button></section>"
399
400/* One section, two ways in. Reading a card and typing an address answer the same
401 * question — where do takings go — so they were two cards headed "Card addresses"
402 * and "Send to", which made the operator choose between two settings before
403 * finding out they were one. Card route first (it is the one that cannot be
404 * mistyped), typing under a divider heading; either way the terminal only
405 * *proposes*, and somebody accepts it on the panel. */
406"<section id=s_addr hidden><h2>Payout addresses</h2>"
407"<p>Where takings are sent &mdash; the merchant's own address. Either way this "
408"only <i>proposes</i> it: the terminal shows it on its own screen and somebody "
409"has to accept it there.</p>"
410/* Two addresses, three networks. Polygon is EVM — same card key, same derivation
411 * path, same 0x address — so it spends the Ethereum one, and saying so here is
412 * what stops an operator hunting for a Polygon field that will never exist and
413 * concluding the terminal cannot take Polygon payments. */
414"<p>Ethereum &mdash; also used for Polygon &mdash; currently "
415"<code id=cur_eth>&hellip;</code><br>"
416"Tron &mdash; currently <code id=cur_trx>&hellip;</code></p>"
417/* Which card, and what to do with it. The instruction below says "tap a Cryptnox
418 * card" to somebody who may never have seen one, so show them the actual card
419 * front (assets/cryptnox_card.png, inlined by tools/gen_card_front.py) rather than a
420 * line drawing of a generic one — the thing in their hand is what they have to
421 * recognise. Decorative: the paragraph beside it says everything the picture does. */
422"<div class=cardrow><img class=cardart src='" CARD_FRONT_PNG_URI "' alt=''>"
423"<p>Whichever card is tapped, <i>its own</i> addresses become the ones takings "
424"are sent to &mdash; so tap the merchant's card, not a customer's. The terminal "
425"asks for that card's PIN, then shows each address for acceptance.</p></div>"
426"<button class=alt id=go_card>Read from a Cryptnox card</button>"
427"<h2>Or type an address</h2>"
428"<p>Ethereum <i>(and Polygon &mdash; one address serves both)</i></p>"
429"<input id=in_eth placeholder='0x...' autocapitalize=off autocomplete=off>"
430"<button id=go_eth>Propose Ethereum address</button>"
431"<p>Tron</p>"
432"<input id=in_trx placeholder='T...' autocapitalize=off autocomplete=off>"
433"<button id=go_trx>Propose Tron address</button></section>"
434
435"<section id=s_wifi hidden><h2>Wi-Fi</h2>"
436"<p id=wifi_note></p>"
437"<p>Currently <code id=cur_ssid>&hellip;</code></p>"
438"<label for=ssid>SSID:</label>"
439"<select id=ssid></select>"
440/* The eye is the panel's (ui.cpp's Wi-Fi keyboard has one): a venue passphrase
441 * typed blind on a phone and rejected tells the operator nothing about which of
442 * the two got it wrong. */
443"<label for=wpass>Password:</label>"
444"<div class=pw><input id=wpass type=password autocomplete=off>"
445"<button type=button class=eye id=eye aria-label='Show password' "
446"aria-pressed=false>"
447/* Feather's eye / eye-off (MIT), which Lucide, Heroicons and every phone keyboard's
448 * own reveal button all draw a version of — the shape people already know. Inline
449 * paths rather than a glyph: an emoji renders as a different picture on every
450 * handset (and in colour on some), and a font or an <img> would be a second request
451 * on a captive portal that has nowhere to fetch it from. Stroked in currentColor,
452 * so it follows the colour scheme for free. */
453"<svg class=on viewBox='0 0 24 24' aria-hidden=true>"
454"<path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/>"
455"<circle cx=12 cy=12 r='3'/></svg>"
456"<svg class=off viewBox='0 0 24 24' aria-hidden=true>"
457"<path d='M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 "
458"5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 "
459"3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24'/>"
460"<path d='M1 1l22 22'/></svg>"
461"</button></div>"
462"<button id=go_wifi>Join this network</button>"
463"<button class=alt id=go_rescan>Scan again</button></section>"
464
465/* No "check for updates" button: this page is served on the terminal's own
466 * SoftAP with nothing behind it, so the browser reading it cannot reach a
467 * release list. Download the image on something that has internet, bring it
468 * here. The native file input is hidden behind our own button so the control
469 * reads like the rest of the page — and it says Browse, because picking the
470 * file is all it does; the terminal's own screen is where it gets installed. */
471"<section id=s_fw hidden><h2>Firmware</h2>"
472"<p>Download the signed <code>.bin</code> on a device that has internet, then "
473"hand it to the terminal here &mdash; the terminal never connects to the "
474"internet for this. It checks the signature itself, and nothing is installed "
475"until somebody accepts the version on its screen.</p>"
476/* No accept= filter. It said '.bin', and iOS maps an accept list to file types it
477 * knows — an extension it does not recognise leaves every file in the picker
478 * greyed out, so the operator gets a file browser that will not let them pick the
479 * file they came to send. The terminal is the wrong place to be lax about what it
480 * accepts and it is not being lax: the image is SHA-256'd and signature-checked
481 * before anything is staged, and a version has to be accepted on the panel. A
482 * filename filter never protected any of that. */
483"<input type=file id=file class=sr>"
484"<button class=alt id=up>Browse</button>"
485"<p id=fwfile></p>"
486
487/* The one thing on this page that a captive portal cannot do.
488 *
489 * Joining the terminal's AP makes the phone pop this page up by itself, in the
490 * Wi-Fi sign-in window — Android's CaptivePortalLogin activity, iOS's equivalent
491 * sheet. That window is a cut-down WebView, and neither platform wires up a file
492 * chooser in it: the input is there, the tap reaches it, and nothing opens. No
493 * markup fixes that, because the missing piece is on the other side of the
494 * WebView. Everything else on this page works there, which is precisely why it
495 * reads as the button being broken rather than as the window being the wrong one.
496 *
497 * So the section says so, in the place the operator is standing when it happens,
498 * and gives them the address to open in a real browser. Shown to everybody rather
499 * than sniffed for from the user agent: the WebView markers are undocumented and
500 * change, and a sentence that is merely unnecessary on a laptop is cheaper than a
501 * detection that is silently wrong on a handset. */
502"<div class='wait prose'><b>Browse does nothing?</b> You are in the Wi-Fi sign-in "
503"window your phone opened, and neither Android nor iOS lets that window pick "
504"files. Stay on this network, open <code>" PORTAL_URL "</code> in your normal "
505"browser, and use the Firmware section there.</div></section>"
506
507/* Where the wizard ends, and the last thing this phone will be shown: joining
508 * the venue network moves the terminal's radio off this setup network, so the
509 * page is about to lose the device it is talking to. Said as a finished job
510 * rather than as a dropped connection — and it replaces the whole page, because
511 * every form behind it is now addressed to something that is not there.
512 *
513 * It is not the last word, though: if the join fails the terminal comes back on
514 * this same setup network, the phone rejoins it by itself, and the poll below
515 * puts the Wi-Fi step back with the reason on it. */
516"<section id=s_final hidden><h2>Configuration complete</h2>"
517"<p>Please follow the instructions on the terminal's screen.</p>"
518"<div class=wait><span class=spin></span>"
519"<b>You can close this page. If the terminal could not join that network it "
520"will reopen this one, and this page will come back by itself.</b></div>"
521"</section>"
522
523"<section id=nav hidden><button id=go_next>Continue</button></section>"
524"</main>";
525
526/* Split so neither literal is unreasonable to read, and so the CSS/HTML above can
527 * be edited without scrolling past the script. Concatenated by page_get(). */
528static const char *const PAGE_JS =
529"<script>"
530"var $=function(i){return document.getElementById(i)};"
531"var T='',S={},G=-1,asked=false,fin=false,seeded=false;"
532/* The token survives a reload in sessionStorage: the terminal only hands it to
533 * the browser that holds it, so a reload that forgot it would be locked out. */
534"try{T=sessionStorage.getItem('pt')||''}catch(e){}"
535"function keep(t){T=t;try{if(t)sessionStorage.setItem('pt',t);"
536"else sessionStorage.removeItem('pt')}catch(e){}}"
537"function pc(t){return('000'+(parseInt(t.substr(0,4),16)%10000)).slice(-4)}"
538/* Every section render() can show, so the finished screen can be made exclusive
539 * by construction rather than by adding `&&!fin` to every show() line — and
540 * so a section added later without a thought for the end of the wizard is hidden
541 * there rather than left on screen addressed to a terminal that has gone. */
542"var SEC=['s_auth','waiting','s_pend','s_addr','s_net','s_ct','s_fee','s_clock',"
543"'s_wifi','s_fw','nav'];"
544/* Three kinds, because "that is not a valid Ethereum address" and "it is stored"
545 * in the same grey box is how a refusal gets read as a success. 'err' is the one
546 * that matters, so it is what a bare say() rejection handler produces: every
547 * post() failure path is an error, and none of them has to remember to say so. */
548"var say=function(t,k){var m=$('msg');m.textContent=t||'';"
549"m.className=t?(k||'err'):''};"
550"var info=function(t){say(t,'info')};"
551"var good=function(t){say(t,'ok')};"
552
553/* Every mutating call carries the session token. A 401 means the portal was
554 * restarted or the window closed, so drop the token and let the render fall back
555 * to the authorise section rather than looping on a dead session. */
556"function post(u,b){var h={'X-Prov-Token':T};"
557"if(b!==undefined)h['Content-Type']='application/x-www-form-urlencoded';"
558"return fetch(u,{method:'POST',headers:h,body:b}).then(function(r){"
559"return r.text().then(function(t){"
560"if(r.status==401){keep('');S.authed=false;asked=false}"
561"if(!r.ok)throw (t||('HTTP '+r.status));return t})})}"
562
563"function enc(o){var a=[];for(var k in o)"
564"a.push(k+'='+encodeURIComponent(o[k]));return a.join('&')}"
565
566"function show(i,on){$(i).hidden=!on}"
567
568"function render(){"
569"var w=(S.mode=='wizard'),st=S.step||'idle',a=!!S.authed,p=!!S.pending;"
570/* Shown with a 'v', like the panel's About tab. The JSON stays bare so nothing
571 * parsing /api/state has to strip it — see ota_version_display(). */
572"$('ver').textContent=S.version?'v'+S.version:'?';"
573"$('note').textContent=S.note||'';"
574"show('s_final',fin);"
575"if(fin){SEC.forEach(function(i){show(i,false)});return}"
576"show('s_auth',!a);show('waiting',!a&&!!S.auth_pending);"
577"$('pair').textContent=T?'Check the terminal shows Browser '+pc(T)+', then enter "
578"the code there.':'';"
579"show('s_pend',a&&p);"
580/* In the wizard one section at a time, in the order of the flow. In admin mode
581 * everything at once — it is a settings page, not a sequence. */
582"show('s_addr',a&&!p&&(w?st=='addr':true));"
583/* Admin mode only, like the contracts and the fees. During the wizard the
584 * terminal is being set up on whatever network it shipped configured for, and a
585 * switch there would restart the device mid-setup — out of the wizard, and away
586 * from the phone that was walking somebody through it. */
587"show('s_net', a&&!p&&!w);"
588"show('s_ct', a&&!p&&!w);"
589"show('s_fee', a&&!p&&!w);"
590"show('s_clock',a&&!p&&!w);"
591"show('s_wifi',a&&!p&&(w?st=='wifi':true));"
592"show('s_fw', a&&!p&&!w);"
593/* Continue exists to leave the address step. There is nothing after the Wi-Fi one
594 * to continue to — joining is what ends the wizard — so it is not offered there. */
595"show('nav', a&&!p&&w&&st=='addr');"
596"$('wifi_note').textContent=w?'This is the last step here: the terminal has one "
597"radio, so joining your network drops this setup network, and the rest is on the "
598"terminal screen.':'Scanning briefly interrupts this page - it comes "
599"back. Changing network drops it for good, on the old address.';"
600"if(a){$('cur_eth').textContent=S.pay_eth||'not set';"
601"$('cur_trx').textContent=S.pay_trx||'not set';"
602/* The contract in use, and where it came from on the line under it. Two elements
603 * rather than one string, so "built into this firmware" is small grey prose and
604 * the address stays the monospace thing you compare against the panel. */
605"$('cur_cte').textContent=S.ct_eth||'none configured';"
606"$('cur_ctt').textContent=S.ct_trx||'none configured';"
607"$('src_cte').textContent=S.ct_eth?(S.ct_eth_own?'Set by an operator on this "
608"terminal.':'Built into this firmware - nothing to do.'):'This firmware names no "
609"contract for it, so that asset is refused.';"
610"$('src_ctt').textContent=S.ct_trx?(S.ct_trx_own?'Set by an operator on this "
611"terminal.':'Built into this firmware - nothing to do.'):'This firmware names no "
612"contract for it, so that asset is refused.';"
613"$('cur_net').textContent=S.mainnet?'production':'test networks';"
614/* Checked from the device once, then left alone — the poll runs every couple of
615 * seconds and would otherwise put the radio back under whoever had just moved it. */
616"if(!$('net_main').checked&&!$('net_test').checked)"
617"$(S.mainnet?'net_main':'net_test').checked=true;"
618"$('cur_fmax').textContent=S.fee_max;"
619"$('cur_fprio').textContent=S.fee_prio;"
620/* Seeded once, on the first state that carries them, and never written again:
621 * the poll runs every couple of seconds, and writing these every tick would take
622 * a digit out from under whoever is typing. Keyed on a flag rather than on the
623 * field being empty, because empty is where you are the instant you backspace
624 * one to retype it — that put the old number straight back, mid-edit. */
625"$('cur_tz').textContent=tzname(S.tz_off|0,S.tz_dst|0);"
626/* Seeded with the fees and on the same flag, for the same reason: the poll runs
627 * every couple of seconds, and writing the select every tick would put the
628 * stored offset back under an operator who had just picked a different one and
629 * not yet saved it. */
630"if(!seeded&&S.fee_max){seeded=true;"
631"$('in_fmax').value=S.fee_max;$('in_fprio').value=S.fee_prio;"
632"$('in_tz').value=(S.tz_off|0)+','+(S.tz_dst|0)}"
633"$('cur_ssid').textContent=S.ssid||'not set';"
634"$('pend').textContent=S.pending||'';"
635"if(S.scan_gen!==G){G=S.scan_gen;scan()}}}"
636
637/* The device's own scan, not the browser's — a browser cannot see Wi-Fi at all.
638 * Rebuilt with textContent per option so an SSID cannot inject markup. */
639"function scan(){fetch('/api/scan',{headers:{'X-Prov-Token':T},"
640"cache:'no-store'}).then(function(r){return r.json()}).then(function(j){"
641"var s=$('ssid');s.textContent='';"
642"if(!j.aps||!j.aps.length){var o=document.createElement('option');"
643"o.textContent='No networks found';o.disabled=true;s.appendChild(o);return}"
644"j.aps.forEach(function(n){var o=document.createElement('option');"
645"o.value=n.ssid;o.textContent=n.ssid+' ('+n.rssi+' dBm)'+(n.open?' open':'');"
646"s.appendChild(o)})}).catch(function(){})}"
647
648/* Asking is automatic on arrival, so the panel is already demanding the code by
649 * the time the operator looks up from the phone — "connect, then type it on the
650 * terminal" with nothing in between. The button is only there to try again after a
651 * refusal. Once per page load: a re-ask mints a new token and would throw away a
652 * session somebody had already been granted. */
653/* The token goes on this one too, and it is not optional: /api/state answers an
654 * unauthorised request with a deliberately minimal body, so a poll without the
655 * token reports authed:false forever — the panel takes the admin code, grants the
656 * session, and the page sits on "Authorize this browser" with no way out. */
657"function poll(){fetch('/api/state',{headers:{'X-Prov-Token':T},"
658"cache:'no-store'})"
659".then(function(r){return r.json()}).then(function(j){S=j;"
660/* The poll that keeps running after the wizard's last screen is what waits for
661 * the device to come back. It answers again while the join is being attempted —
662 * the radio serves both networks for those few seconds — so an answer alone is
663 * not the terminal returning. Something to report is: the terminal writes a note
664 * when the network would not take it, and nothing else brings the wizard back to
665 * a step it can act on. Either way the operator loses nothing by looking at the
666 * panel, which is what the final screen tells them to do. */
667"if(fin&&(S.note||(S.step&&S.step!='wifi')))fin=false;"
668"render();"
669"if(!S.authed&&!S.auth_pending&&!asked)ask()})"
670".catch(function(){})}"
671
672"function ask(){asked=true;say('');"
673"post('/api/auth').then(function(t){keep(t);S.auth_pending=!S.authed;render()},"
674"function(e){asked=false;say(e)})}"
675"$('go_auth').onclick=ask;"
676
677"$('go_card').onclick=function(){"
678"post('/api/card').then(function(){info('Tap your Cryptnox card on the terminal "
679"when it asks, then accept each address on its screen.')},say)};"
680
681/* An empty field is not a failure, it is a step not taken yet — so the answer says
682 * what to do next instead of reporting that nothing happened. "Nothing to
683 * propose." was read as the terminal refusing the address rather than as never
684 * having been given one, and there is no way to tell those apart from a red line
685 * that describes the page's own internal state. Named per field, because this is
686 * the same button four times over and the operator has to know which one. */
687"function propose(u,net,el){var v=$(el).value.trim();"
688"if(!v){info('That box is empty - type the address into it first.');"
689"$(el).focus();return}"
690"post(u,enc({net:net,addr:v})).then(function(m){$(el).value='';good(m)},say)}"
691"$('go_eth').onclick=function(){propose('/api/payout','eth','in_eth')};"
692"$('go_trx').onclick=function(){propose('/api/payout','tron','in_trx')};"
693"$('go_cte').onclick=function(){propose('/api/contract','eth','in_cte')};"
694"$('go_ctt').onclick=function(){propose('/api/contract','tron','in_ctt')};"
695
696/* The device reboots on this one, so the answer is the last thing this page will
697 * hear from it: stop the poll and say so, rather than leaving the operator
698 * watching a page that quietly stops updating. */
699"$('go_net').onclick=function(){"
700"var m=$('net_main').checked;"
701"if(m==!!S.mainnet){info('Already on that network.');return}"
702"post('/api/network',enc({net:m?'main':'test'})).then(function(t){"
703"clearInterval(PT);good(t)},say)};"
704
705"$('go_fee').onclick=function(){"
706"post('/api/fees',enc({max:$('in_fmax').value,prio:$('in_fprio').value}))"
707".then(good,say)};"
708
709/* Every offset the world actually uses, in minutes east of UTC. A list rather
710 * than a loop over whole hours: the quarter- and half-hour zones are real, and
711 * a terminal in Kathmandu offered +5:00 is a terminal that is 45 minutes wrong.
712 * Filled in by script rather than written out as 38 <option> tags, which is
713 * about a kilobyte of page for the same result — and the page is flash. */
714"var TZ=[-720,-660,-600,-570,-540,-480,-420,-360,-300,-240,-210,-180,-120,-60,"
715"0,60,120,180,210,240,270,300,330,345,360,390,420,480,525,540,570,600,630,660,"
716"720,765,780,840];"
717"function tzlabel(m){var s=m<0?'-':'+',a=m<0?-m:m;"
718"return'UTC'+s+('0'+Math.floor(a/60)).slice(-2)+':'+('0'+(a%60)).slice(-2)}"
719/* [standard offset, DST rule, places]: one row per zone, so the list is one
720 * line per time the clock can show. Rule 1 EU, 2 US/Canada, 3 SE Australia,
721 * 4 New Zealand, 0 none (civil_dst_t). Places with other DST rules (Chile,
722 * Paraguay, Israel, Egypt, Morocco...) are left to the bare offsets. */
723"var REG=["
724"[-600,0,'Honolulu'],[-540,2,'Anchorage'],"
725"[-480,2,'Los Angeles, San Francisco, Seattle, Vancouver, Tijuana'],"
726"[-420,2,'Denver, Salt Lake City, Calgary, Edmonton'],[-420,0,'Phoenix'],"
727"[-360,2,'Chicago, Dallas, Houston, Winnipeg'],"
728"[-360,0,'Mexico City, Guadalajara, Regina, Guatemala, San Salvador, San Jose'],"
729"[-300,2,'New York, Washington, Miami, Toronto, Montreal, Ottawa, Nassau, Port-au-Prince'],"
730"[-300,0,'Bogota, Lima, Quito, Panama, Kingston, Cancun'],"
731"[-240,2,'Halifax, Bermuda'],[-240,0,'San Juan, Santo Domingo, Caracas, La Paz'],"
732"[-210,2,'Newfoundland'],"
733"[-180,0,'Sao Paulo, Rio de Janeiro, Brasilia, Buenos Aires, Montevideo'],"
734"[0,1,'London, Dublin, Lisbon, Canary Islands'],[0,0,'Reykjavik, Accra, Dakar, Abidjan'],"
735"[60,1,'Zurich, Geneva, Paris, Berlin, Frankfurt, Rome, Milan, Madrid, Barcelona, "
736"Amsterdam, Brussels, Luxembourg, Vienna, Vaduz, Monaco, Copenhagen, Oslo, Stockholm, "
737"Warsaw, Prague, Bratislava, Budapest, Ljubljana, Zagreb, Belgrade, Sarajevo, "
738"Podgorica, Skopje, Tirana, Valletta'],"
739"[60,0,'Lagos, Algiers, Tunis'],"
740"[120,1,'Helsinki, Tallinn, Riga, Vilnius, Athens, Sofia, Bucharest, Nicosia, Kyiv'],"
741"[120,0,'Johannesburg, Cape Town, Harare, Kigali'],"
742"[180,0,'Istanbul, Moscow, Minsk, Riyadh, Doha, Kuwait, Manama, Baghdad, Nairobi, "
743"Addis Ababa'],"
744"[210,0,'Tehran'],[240,0,'Dubai, Abu Dhabi, Muscat, Baku, Tbilisi, Yerevan, Mauritius'],"
745"[270,0,'Kabul'],[300,0,'Karachi, Lahore, Tashkent, Almaty, Astana, Maldives'],"
746"[330,0,'Mumbai, Delhi, Bangalore, Colombo'],[345,0,'Kathmandu'],[360,0,'Dhaka'],"
747"[390,0,'Yangon'],[420,0,'Bangkok, Hanoi, Ho Chi Minh City, Jakarta, Phnom Penh'],"
748"[480,0,'Beijing, Shanghai, Hong Kong, Macau, Taipei, Singapore, Kuala Lumpur, "
749"Manila, Bali, Perth'],"
750"[540,0,'Tokyo, Osaka, Seoul'],[570,3,'Adelaide'],[570,0,'Darwin'],"
751"[600,3,'Sydney, Canberra, Melbourne, Hobart'],[600,0,'Brisbane, Guam, Port Moresby'],"
752"[720,4,'Auckland, Wellington'],[720,0,'Fiji']];"
753/* Every offset with no row of its own gets a bare one, so the list still
754 * reaches every zone the terminal can show. Sorted by offset, DST row first. */
755"TZ.forEach(function(m){if(!REG.some(function(r){return r[0]==m&&!r[1]}))REG.push([m,0,''])});"
756"REG.sort(function(a,b){return a[0]-b[0]||b[1]-a[1]});"
757"var DSTN=['','EU DST','US DST','AU DST','NZ DST'];"
758"function tzrow(r){return tzlabel(r[0])+(r[1]?' '+DSTN[r[1]]:'')+(r[2]?' - '+r[2]:'')}"
759"function tzname(m,d){for(var i=0;i<REG.length;i++)"
760"if(REG[i][0]==m&&REG[i][1]==d)return tzrow(REG[i]);return tzlabel(m)}"
761"REG.forEach(function(r){var o=document.createElement('option');"
762"o.value=r[0]+','+r[1];o.textContent=tzrow(r);$('in_tz').appendChild(o)});"
763
764"$('go_clock').onclick=function(){var z=$('in_tz').value.split(',');"
765"post('/api/clock',enc({off:z[0],dst:z[1]})).then(good,say)};"
766
767"$('eye').onclick=function(){var p=$('wpass'),r=(p.type=='password');"
768"p.type=r?'text':'password';this.setAttribute('aria-pressed',r);"
769"this.setAttribute('aria-label',r?'Hide password':'Show password')};"
770
771"$('go_wifi').onclick=function(){var s=$('ssid').value;"
772"if(!s){say('Pick a network first.');return}"
773"post('/api/wifi',enc({ssid:s,pass:$('wpass').value})).then(function(m){"
774"$('wpass').value='';"
775/* In the wizard, handing over the network is the end of this page's job: the
776 * terminal moves its radio to that network and this setup network goes with it.
777 * So the page stops being a form and becomes a finished screen, rather than
778 * leaving the operator on a Wi-Fi step whose buttons now reach nothing. In admin
779 * mode the terminal is only changing networks, so the ordinary message stands. */
780"if(S.mode=='wizard'){say('');fin=true;render()}else{good(m)}},say)};"
781"$('go_rescan').onclick=function(){info('Scanning\\u2026');"
782"post('/api/rescan').then(function(){},say)};"
783"$('go_next').onclick=function(){post('/api/next').then(function(){},say)};"
784
785/* XHR, not fetch: this is the leg that can stall with the flash half written,
786 * and only XHR reports upload progress. */
787"function send(buf){return new Promise(function(res,rej){"
788"var x=new XMLHttpRequest(),sent=false;x.open('POST','/api/ota');"
789"x.setRequestHeader('Content-Type','application/octet-stream');"
790"x.setRequestHeader('X-Prov-Token',T);"
791"x.upload.onprogress=function(e){if(e.lengthComputable)"
792"info('Sending to the terminal: '+Math.round(e.loaded/e.total*100)+'%')};"
793"x.upload.onload=function(){sent=true};"
794"x.onload=function(){x.status==200?res(x.responseText):"
795"rej(x.responseText||('HTTP '+x.status))};"
796/* Two different events wearing one word. A drop with bytes still to send really
797 * did lose the image. A drop after the last byte went out lost only the answer —
798 * the terminal has the whole file and is verifying it, and saying "not installed"
799 * about a version that is at that moment on the panel waiting to be accepted is
800 * how an operator uploads the same firmware three times. */
801"x.onerror=function(){rej(sent?'the file went across but the terminal did not "
802"answer - if a version is showing on its screen it arrived, accept it there'"
803":'the connection to the terminal dropped')};"
804"x.send(buf)})}"
805
806/* The button drives the hidden native picker, and picking is what starts the
807 * transfer — there is no second "install this file" step, because nothing this
808 * page does installs anything: the terminal verifies the signature and the
809 * version is accepted on its own screen. A confirm button in front of that would
810 * guard a transfer, not an installation. */
811"$('up').onclick=function(){$('file').click()};"
812/* The poll stops for the transfer. Every tick is another connection, the terminal
813 * has a handful of sockets, and the upload is the one request that must not be the
814 * casualty when they run out (see prov_start). Nothing is lost by pausing: the
815 * progress line comes from the XHR itself, and the poll resumes in time to show
816 * whatever the panel decides. */
817"$('file').onchange=function(){var el=this,f=el.files[0];if(!f)return;"
818"$('fwfile').textContent=f.name;$('up').disabled=true;clearInterval(PT);"
819"f.arrayBuffer().then(send).then(good,function(e){say('Not installed: '+e)})"
820/* Clear it, or picking the same file again is not a change event and the button
821 * looks broken on a retry. */
822".then(function(){$('up').disabled=false;el.value='';PT=setInterval(poll,1500)})};"
823
824"var PT=setInterval(poll,1500);poll();"
825"</script></body></html>";
826
827#endif /* PORTAL_PAGE_H */
#define PORTAL_FONTS_CSS
static const char *const PAGE_HTML
Definition portal_page.h:40
static const char *const PAGE_JS
#define PORTAL_URL
Definition portal_page.h:34