cryptnox-pos
1.0.0
Standalone USDC payment terminal firmware (ESP32 + Cryptnox smart card)
Toggle main menu visibility
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. */
40
static
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>"
44
PORTAL_FONTS_CSS
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>…</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
"— 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…</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>…</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 — 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 — <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 — using <code id=cur_cte>…</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 — using <code id=cur_ctt>…</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 — currently "
375
"<code id=cur_fmax>…</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) — currently "
378
"<code id=cur_fprio>…</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 — "
391
"currently <code id=cur_tz>…</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 — 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 — also used for Polygon — currently "
415
"<code id=cur_eth>…</code><br>"
416
"Tron — currently <code id=cur_trx>…</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 — 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 — 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>…</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 — 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(). */
528
static
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&®[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 */
portal_fonts.h
PORTAL_FONTS_CSS
#define PORTAL_FONTS_CSS
Definition
portal_fonts.h:16
PAGE_HTML
static const char *const PAGE_HTML
Definition
portal_page.h:40
PAGE_JS
static const char *const PAGE_JS
Definition
portal_page.h:528
PORTAL_URL
#define PORTAL_URL
Definition
portal_page.h:34
main
portal_page.h
Generated by
1.17.0