pendify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pendify/page.py ADDED
@@ -0,0 +1,1136 @@
1
+ """The pairing page, served on 127.0.0.1 only (design P2, P5, P6, residuals a and e).
2
+
3
+ Fences: a request whose Host is not 127.0.0.1:<port> or localhost:<port> answers 403, so a page on
4
+ another name that rebinds to this address can never read the secret; every POST carries this run's
5
+ random token, else 403; every answer carries no-store, no-referrer, DENY and a policy that loads
6
+ nothing from any origin; no request line and no body is ever logged.
7
+ """
8
+ import hmac
9
+ import html
10
+ import json
11
+ import secrets
12
+ import sys
13
+ import threading
14
+ import time
15
+ import urllib.parse
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+
18
+ from . import codes, config, icon, plate_almena, qr
19
+
20
+ ADDRESS = "127.0.0.1"
21
+ MAX_FORM_BYTES = 4096
22
+ # A POST's body is read up to this bound before any answer, refusals included: a socket closed with
23
+ # unread bytes in it is reset on Windows, and the client meets the reset in place of the answer.
24
+ DRAIN_BYTES = 64 * 1024
25
+ # With no usable length the socket is read up to DRAIN_BYTES while bytes keep coming, each read waiting this long.
26
+ DRAIN_WAIT = 0.1
27
+ # img-src data: serves the QR scene's plate and the program's icon, each inlined as a data URI; none loads from an origin.
28
+ POLICY = ("default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; "
29
+ "form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
30
+ # The program's icon (icon.py, written by tools/make_icon.py), its 32 px PNG inlined as a data URI: the tab icon of both
31
+ # documents and the image left of the pairing page's title, allowed by img-src data: with no new origin.
32
+ ICON_URI = "data:image/png;base64," + icon.PNG_32
33
+ _TAB_ICON = f'<link rel="icon" type="image/png" href="{ICON_URI}">'
34
+ FENCE_HEADERS = (("Cache-Control", "no-store"), ("Referrer-Policy", "no-referrer"), ("X-Frame-Options", "DENY"),
35
+ ("Content-Security-Policy", POLICY), ("X-Content-Type-Options", "nosniff"))
36
+ # The program's name (owner 2026-10-01): the title and the h1 of the pairing page and of the stopped page.
37
+ NAME = "PendiFy"
38
+ # The foot's credit: the creator's handle and the repository, a link out that opens in a new tab with no opener and
39
+ # no referrer. A link loads nothing, so the policy above is unchanged.
40
+ CREATOR = "ElkinDev"
41
+ REPOSITORY = "https://github.com/ElkinDev/PendiFy"
42
+ # The address the linked page's sponsored QR encodes.
43
+ SPONSOR_ADDRESS = "https://pendiapp.com"
44
+
45
+ # "title" is the sentence under the program's name, the tagline; NAME is the title and the h1 of both documents.
46
+ WORDS = {
47
+ "es": {
48
+ "title": "Avisos de este PC",
49
+ "intro": "Este programa envía sus avisos a tu cuenta de Pendi. Para eso, este PC tiene que quedar enlazado "
50
+ "con tu cuenta.",
51
+ "scan": "Escanea este código con la cámara del teléfono donde tienes tu cuenta y confirma el enlace.",
52
+ "code_label": "Clave de este PC:",
53
+ "show_code": "Mostrar el código",
54
+ "hide_code": "Ocultar el código",
55
+ "mask": "Clave oculta",
56
+ "fold": "Más opciones: escribir los valores del enlace u olvidar este PC",
57
+ "credit": "Creado por",
58
+ "sponsor_by": "Patrocinado por Pendiapp.com",
59
+ "sponsor_cap": "Escanéalo para abrir pendiapp.com",
60
+ "sponsor_qr": "Código QR de pendiapp.com",
61
+ "sponsor_note": "Este PC ya está enlazado, por eso ya no se muestra su código. «Olvidar este PC», en «Más "
62
+ "opciones», crea un código nuevo para enlazar.",
63
+ "theme_toggle": "Cambiar entre tema claro y oscuro",
64
+ "language": "Idioma",
65
+ "state_waiting": "Esperando la confirmación en el teléfono.",
66
+ "state_wait": "El servicio pidió esperar un momento. Se volverá a preguntar solo.",
67
+ "state_offline": "No se pudo conectar. Se volverá a intentar.",
68
+ "state_paused": "Se dejó de preguntar. Pulsa «Comprobar ahora» para seguir.",
69
+ "state_linked": "Este PC está enlazado. Los avisos llegarán a tu cuenta.",
70
+ "state_linked_paused": "Este PC está enlazado. Los avisos están en pausa.",
71
+ "state_refused": "Tu cuenta ya no acepta los avisos de este PC. Puedes volver a enlazarlo.",
72
+ "state_closed": "El programa no está abierto. Vuelve a iniciarlo para seguir.",
73
+ "check": "Comprobar ahora",
74
+ "button_wait": "Espera un momento antes de volver a comprobar.",
75
+ "typed_title": "¿Creaste el enlace escribiendo los valores? Cópialos aquí:",
76
+ "link_id_label": "Identificador del enlace",
77
+ "secret_label": "Clave",
78
+ "save": "Guardar",
79
+ "typed_refused": "Esos valores no son válidos: cada uno tiene doce caracteres.",
80
+ "config_failed": "No se pudo leer ni guardar la configuración de este PC.",
81
+ "relink": "Volver a enlazar",
82
+ "forget": "Olvidar este PC",
83
+ "forget_sentence": "Olvidar este PC crea una clave nueva y quita el enlace de este PC. El enlace anterior "
84
+ "sigue activo en tu cuenta hasta que lo desenlaces en Pendi o caduque.",
85
+ "watch_waiting": "Esperando el cliente del juego en este PC.",
86
+ "watch_connected": "Conectado al cliente del juego.",
87
+ "watch_paused": "En pausa: este PC no lee el juego ni avisa a tu teléfono.",
88
+ "watch_last": "Último aviso: {what}, a las {time}.",
89
+ "alert_loading": "empezó la pantalla de carga",
90
+ "alert_queue": "partida encontrada",
91
+ "alert_started": "la partida empezó",
92
+ "phase_none": "Sin partida",
93
+ "phase_lobby": "En la sala",
94
+ "phase_matchmaking": "Buscando partida",
95
+ "phase_readycheck": "Partida encontrada",
96
+ "phase_champselect": "Eligiendo",
97
+ "phase_inprogress": "En partida",
98
+ "phase_endofgame": "Fin de la partida",
99
+ "phase_other": "Otro estado",
100
+ "ping_sent": "enviado",
101
+ "ping_refused": "rechazado por tu cuenta",
102
+ "ping_not_delivered": "no entregado",
103
+ "ping_failed": "falló el envío",
104
+ "watch_phase": "Ahora: {phase}.",
105
+ "watch_ping": "Aviso al teléfono: {result}, a las {time}.",
106
+ "log_started": "El programa empezó.",
107
+ "log_waiting": "Esperando el cliente del juego.",
108
+ "log_connected": "Conectado al cliente del juego.",
109
+ "log_lost": "Se perdió el cliente del juego. Buscándolo de nuevo.",
110
+ "log_accepted": "Partida aceptada.",
111
+ "log_loading": "Pantalla de carga: esperando que empiece la partida.",
112
+ "log_match_started": "La partida empezó.",
113
+ "log_ping": "Aviso al teléfono: {result}.",
114
+ "log_paused": "Avisos en pausa.",
115
+ "log_resumed": "Avisos reanudados.",
116
+ "log_title": "Actividad",
117
+ "log_help": "Lo que el programa vio y avisó desde que empezó en este PC.",
118
+ "quit": "Salir",
119
+ "this_pc": "Este PC",
120
+ "pause": "Pausar avisos",
121
+ "resume": "Reanudar avisos",
122
+ "autostart_label": "Iniciar con Windows",
123
+ "autostart_help": "Al encender el PC el programa empieza solo, sin abrir esta página.",
124
+ "stopped": "El programa se detuvo: ya no vigila el cliente del juego ni envía avisos.",
125
+ "start_again": "Para volver a iniciarlo, abre el acceso directo del Escritorio o ejecuta "
126
+ "pythonw -m pendify (o python -m pendify para verlo en una consola).",
127
+ },
128
+ "en": {
129
+ "title": "Alerts from this PC",
130
+ "intro": "This program sends its alerts to your Pendi account. For that, this PC has to be linked to your "
131
+ "account.",
132
+ "scan": "Scan this code with the camera of the phone that holds your account and confirm the link.",
133
+ "code_label": "This PC's key:",
134
+ "show_code": "Show the code",
135
+ "hide_code": "Hide the code",
136
+ "mask": "Key hidden",
137
+ "fold": "More options: type the link values, or forget this PC",
138
+ "credit": "Created by",
139
+ "sponsor_by": "Sponsored by Pendiapp.com",
140
+ "sponsor_cap": "Scan it to open pendiapp.com",
141
+ "sponsor_qr": "QR code of pendiapp.com",
142
+ "sponsor_note": "This PC is already linked, so its code is no longer shown. «Forget this PC», under «More "
143
+ "options», makes a new code to link.",
144
+ "theme_toggle": "Switch between light and dark theme",
145
+ "language": "Language",
146
+ "state_waiting": "Waiting for the confirmation on the phone.",
147
+ "state_wait": "The service asked to wait a moment. It will ask again by itself.",
148
+ "state_offline": "Could not connect. It will try again.",
149
+ "state_paused": "Stopped asking. Press «Check now» to go on.",
150
+ "state_linked": "This PC is linked. Alerts will reach your account.",
151
+ "state_linked_paused": "This PC is linked. Alerts are paused.",
152
+ "state_refused": "Your account no longer accepts this PC's alerts. You can link it again.",
153
+ "state_closed": "The program is not running. Start it again to go on.",
154
+ "check": "Check now",
155
+ "button_wait": "Wait a moment before checking again.",
156
+ "typed_title": "Did you make the link by typing the values? Copy them here:",
157
+ "link_id_label": "Link id",
158
+ "secret_label": "Key",
159
+ "save": "Save",
160
+ "typed_refused": "Those values are not valid: each has twelve characters.",
161
+ "config_failed": "This PC's settings could not be read or saved.",
162
+ "relink": "Link again",
163
+ "forget": "Forget this PC",
164
+ "forget_sentence": "Forgetting this PC makes a new key and removes this PC's link. The old link stays "
165
+ "active in your account until you unlink it in Pendi or it expires.",
166
+ "watch_waiting": "Waiting for the game client on this PC.",
167
+ "watch_connected": "Connected to the game client.",
168
+ "watch_paused": "Paused: this PC is not reading the game or alerting your phone.",
169
+ "watch_last": "Last alert: {what}, at {time}.",
170
+ "alert_loading": "the loading screen started",
171
+ "alert_queue": "match found",
172
+ "alert_started": "the match started",
173
+ "phase_none": "No game",
174
+ "phase_lobby": "In the lobby",
175
+ "phase_matchmaking": "Looking for a match",
176
+ "phase_readycheck": "Match found",
177
+ "phase_champselect": "Choosing",
178
+ "phase_inprogress": "In a game",
179
+ "phase_endofgame": "Game over",
180
+ "phase_other": "Other state",
181
+ "ping_sent": "sent",
182
+ "ping_refused": "refused by your account",
183
+ "ping_not_delivered": "not delivered",
184
+ "ping_failed": "sending failed",
185
+ "watch_phase": "Now: {phase}.",
186
+ "watch_ping": "Alert to the phone: {result}, at {time}.",
187
+ "log_started": "The program started.",
188
+ "log_waiting": "Waiting for the game client.",
189
+ "log_connected": "Connected to the game client.",
190
+ "log_lost": "Lost the game client. Looking for it again.",
191
+ "log_accepted": "Match accepted.",
192
+ "log_loading": "Loading screen: waiting for the match to start.",
193
+ "log_match_started": "The match started.",
194
+ "log_ping": "Alert to the phone: {result}.",
195
+ "log_paused": "Alerts paused.",
196
+ "log_resumed": "Alerts resumed.",
197
+ "log_title": "Activity",
198
+ "log_help": "What the program saw and alerted since it started on this PC.",
199
+ "quit": "Quit",
200
+ "this_pc": "This PC",
201
+ "pause": "Pause alerts",
202
+ "resume": "Resume alerts",
203
+ "autostart_label": "Start with Windows",
204
+ "autostart_help": "When the PC turns on, the program starts by itself, without opening this page.",
205
+ "stopped": "The program stopped: it no longer watches the game client or sends alerts.",
206
+ "start_again": "To start it again, open the shortcut on the Desktop or run pythonw -m pendify "
207
+ "(or python -m pendify to see it in a console).",
208
+ },
209
+ }
210
+
211
+ # The phase as the client names it, to its word; any other name reads phase_other.
212
+ PHASE_WORDS = {"None": "phase_none", "Lobby": "phase_lobby", "Matchmaking": "phase_matchmaking",
213
+ "ReadyCheck": "phase_readycheck", "ChampSelect": "phase_champselect", "InProgress": "phase_inprogress",
214
+ "EndOfGame": "phase_endofgame", "PreEndOfGame": "phase_endofgame", "WaitingForStats": "phase_endofgame"}
215
+ PING_RESULTS = ("sent", "refused", "not_delivered", "failed")
216
+ # The log's lines (lane pclog round 1, the words of briefs/pclog-design-2026-10-01.md): each kind the watcher notes to
217
+ # its word; a phase reads its phase word and a period, a ping its result's word. Lost, the pause and a ping that was
218
+ # not sent are flagged, so a drawing can mark a failure.
219
+ LOG_WORDS = {"started": "log_started", "waiting": "log_waiting", "connected": "log_connected", "lost": "log_lost",
220
+ "accepted": "log_accepted", "loading": "log_loading", "match_started": "log_match_started",
221
+ "paused": "log_paused", "resumed": "log_resumed"}
222
+ LOG_WARNS = frozenset({"lost", "paused"})
223
+ # A line of these kinds ends the phase collapse: the same phase after a reconnect or a resume is shown again.
224
+ LOG_PHASE_BREAKS = frozenset({"lost", "connected", "paused", "resumed"})
225
+
226
+ # The design's stylesheet (page design, round 2 of 2026-09-30): light and dark by the system's choice, system
227
+ # fonts only, nothing loaded. Its form[action=...] selectors quote the value with ' so no page carries the text
228
+ # action="/relink" or action="/quit" of a form it does not show.
229
+ # The theme's colours, light and dark. The system's choice sets them; the theme button's choice, kept in data-theme
230
+ # on the html element, overrides it in either direction; with no choice stored the system's governs (pendiapp.com's
231
+ # rule, assets/tokens.css).
232
+ _LIGHT = ("--bg:#FAF8FE;--card:#FFFFFF;--tint:#EFEAF8;--ink:#1E1533;--ink2:#574E70;"
233
+ "--line:#D8D0EA;--hair:#ECE6F7;--brand:#6D28D9;--on-brand:#FFFFFF;--tonal:#E9DEFB;--on-tonal:#4C1D95;"
234
+ "--danger:#C21F45;--danger-bg:#F6DDE3;--on-danger-bg:#671025;--ring:rgba(109,40,217,.24);--hover:rgba(30,21,"
235
+ "51,.06);--px-line:#1E1533;--shadow:0 1px 2px rgba(30,21,51,.05),0 12px 32px rgba(30,21,51,.07);")
236
+ _DARK = ("--bg:#131022;--card:#1E1A31;--tint:#272138;--ink:#ECE8F6;"
237
+ "--ink2:#A79FC2;--line:#3A3452;--hair:#2B2740;--brand:#C3B1F7;--on-brand:#24124F;--tonal:#40277C;"
238
+ "--on-tonal:#E9DEFB;--danger:#FB7196;--danger-bg:#853C50;--on-danger-bg:#FEEAEF;--ring:rgba(195,177,247,.30);"
239
+ "--hover:rgba(255,255,255,.08);--px-line:#0D0A18;--shadow:0 1px 2px rgba(0,0,0,.30),0 16px 40px rgba(0,0,0,"
240
+ ".32);")
241
+ _STYLE = (":root{color-scheme:light dark;" + _LIGHT + "}\n"
242
+ "@media (prefers-color-scheme:dark){:root{" + _DARK + "}}\n"
243
+ ':root[data-theme="light"]{color-scheme:light;' + _LIGHT + "}\n"
244
+ ':root[data-theme="dark"]{color-scheme:dark;' + _DARK + "}\n"
245
+ "*{box-sizing:border-box}\n"
246
+ "body{margin:0;padding:32px 16px 40px;background:var(--bg);color:var(--ink);font:400 16px/24px system-ui,"
247
+ "sans-serif;-webkit-font-smoothing:antialiased}\n"
248
+ "body>h1,body>p{max-width:40rem;margin-inline:auto}\n"
249
+ ".page{max-width:1040px;margin:0 auto}\n"
250
+ "h1{margin:0 0 8px;font-size:24px;line-height:32px;font-weight:600;letter-spacing:-.01em}\n"
251
+ ".title-row{display:flex;align-items:center;gap:12px;margin:0 0 8px}.title-row h1{margin:0}\n"
252
+ ".title-row img{flex:none;image-rendering:pixelated}\n"
253
+ ".intro{margin:0 0 24px;max-width:46ch;color:var(--ink2)}\n"
254
+ "#state{display:flex;gap:12px;align-items:flex-start;max-width:40rem;margin:0;padding:12px 16px;"
255
+ "border-radius:12px;background:var(--tint);font-weight:500}\n"
256
+ '#state::before{content:"";flex:none;width:10px;height:10px;margin-top:7px;border-radius:50%;'
257
+ "background:var(--brand)}\n"
258
+ '#state[data-shown="falsefalse"]{padding:16px 20px;background:var(--tonal);color:var(--on-tonal);'
259
+ "font-size:20px;line-height:28px;font-weight:600}\n"
260
+ '#state[data-shown="falsefalse"]::before{width:8px;height:15px;margin:3px 4px 0 6px;border-radius:0;'
261
+ "background:none;border:solid currentColor;border-width:0 3px 3px 0;transform:rotate(45deg)}\n"
262
+ '#state[data-shown="falsetrue"]{background:var(--danger-bg);color:var(--on-danger-bg)}\n'
263
+ '#state[data-shown="falsetrue"]::before{background:none;border:2px solid currentColor}\n'
264
+ "#watch{margin:12px 0 0;max-width:40rem;font-size:14px;line-height:20px;color:var(--ink2)}\n"
265
+ ".scan{margin:24px 0 16px;max-width:40ch}\n"
266
+ ".note{margin:12px 0 0;font-size:14px;line-height:20px;color:var(--ink2)}\n"
267
+ ".note.warn{color:var(--danger)}\n"
268
+ ".key-card{display:grid;justify-items:center;gap:16px;margin:0 0 16px;padding:20px;border-radius:16px;"
269
+ "background:var(--card);border:1px solid var(--hair);box-shadow:var(--shadow)}\n"
270
+ ".qr{display:block;width:100%;max-width:288px;height:auto;border-radius:12px;"
271
+ "box-shadow:0 0 0 1px var(--hair)}\n"
272
+ ".key{display:grid;gap:2px;margin:0}\n"
273
+ ".key-label{font-size:12px;line-height:16px;font-weight:500;color:var(--ink2)}\n"
274
+ # The header's actions as pendiapp.com's .nav-actions holds them: the language switch, then the theme button.
275
+ ".bar{display:flex;justify-content:flex-end;align-items:center;gap:.45rem;margin:0 0 8px}\n"
276
+ # pendiapp.com's language switch (assets/styles.css, .langsw), its variables mapped to the page's own:
277
+ # --hairline to --hair, --muted to --ink2, --brand and --on-brand as they are. Each entry is a submit
278
+ # button, so the page's button look is set back to the site's link: no minimum height, no border, no fill,
279
+ # the group's font; the page's button focus ring stays. The switch is one form, the pill: .langsw's inline
280
+ # flex and the page's form{margin:0} already sit it inline as the span did, so no rule is added for it.
281
+ ".langsw{display:inline-flex;border:1px solid var(--hair);border-radius:999px;padding:2px;font-size:.8rem;"
282
+ "font-weight:600}\n"
283
+ ".langsw button{min-height:0;padding:.28rem .62rem;border:0;border-radius:999px;background:transparent;"
284
+ "color:var(--ink2);font:inherit}\n"
285
+ '.langsw button[aria-current="true"]{background:var(--brand);color:var(--on-brand)}\n'
286
+ ".icon-btn{display:inline-grid;place-items:center;width:44px;height:44px;min-height:0;padding:0;"
287
+ "border-radius:999px;border:1px solid var(--hair);background:transparent;color:var(--ink);cursor:pointer;"
288
+ "transition:background .2s cubic-bezier(.23,1,.32,1),transform .2s cubic-bezier(.23,1,.32,1)}\n"
289
+ ".icon-btn:hover{background:var(--tint);box-shadow:none}\n"
290
+ ".icon-btn:active{transform:scale(.94)}\n"
291
+ ".icon-btn .sun{display:none}\n"
292
+ ".icon-btn .moon{display:block}\n"
293
+ '@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .icon-btn .sun{display:block}'
294
+ ':root:not([data-theme="light"]) .icon-btn .moon{display:none}}\n'
295
+ ':root[data-theme="dark"] .icon-btn .sun{display:block}\n'
296
+ ':root[data-theme="dark"] .icon-btn .moon{display:none}\n'
297
+ ':root[data-theme="light"] .icon-btn .sun{display:none}\n'
298
+ ':root[data-theme="light"] .icon-btn .moon{display:block}\n'
299
+ '.code{font:600 22px/28px ui-monospace,"Cascadia Mono",Consolas,monospace;letter-spacing:.12em;'
300
+ "font-variant-numeric:tabular-nums}\n"
301
+ ".party{display:flex;justify-content:center;align-items:flex-end;gap:18px;width:100%;padding-top:4px;"
302
+ "border-bottom:2px solid var(--line)}\n"
303
+ ".px{display:block;width:48px;height:48px}\n"
304
+ ".pl{fill:var(--px-line)}\n"
305
+ "form{margin:0}\n"
306
+ "button{min-height:44px;padding:10px 24px;border:1px solid transparent;"
307
+ "border-radius:999px;background:var(--tonal);color:var(--on-tonal);font:500 14px/20px system-ui,"
308
+ "sans-serif;cursor:pointer;transition:transform 160ms cubic-bezier(.23,1,.32,1),box-shadow 160ms ease}\n"
309
+ "button:hover{box-shadow:inset 0 0 0 999px var(--hover)}\n"
310
+ "button:active{transform:scale(.97)}\n"
311
+ "button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
312
+ "form[action='/check'] button,form[action='/relink'] button{width:100%;background:var(--brand);"
313
+ "color:var(--on-brand)}\n"
314
+ "form[action='/forget'] button{background:transparent;border-color:var(--line);color:var(--danger)}\n"
315
+ "form[action='/quit'] button{padding-inline:16px;background:transparent;color:var(--ink2)}\n"
316
+ ".more{display:grid;gap:16px;margin-top:32px}\n"
317
+ ".panel{padding:20px;border-radius:16px;background:var(--card);border:1px solid var(--hair)}\n"
318
+ ".panel h2{margin:0 0 16px;font-size:16px;line-height:24px;font-weight:600}\n"
319
+ ".panel p{margin:0 0 16px;font-size:14px;line-height:20px;color:var(--ink2)}\n"
320
+ ".panel .note{margin:12px 0 0}\n"
321
+ "form[action='/typed']{display:grid;gap:12px}\n"
322
+ "label{display:grid;gap:6px;font-size:14px;line-height:20px;font-weight:500;color:var(--ink2)}\n"
323
+ "input{width:100%;height:44px;padding:0 14px;border:1px solid var(--line);border-radius:12px;"
324
+ 'background:var(--bg);color:var(--ink);font:500 16px/24px ui-monospace,"Cascadia Mono",Consolas,monospace;'
325
+ "letter-spacing:.08em;text-transform:uppercase}\n"
326
+ "input:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--ring)}\n"
327
+ "form[action='/typed'] button{justify-self:start;margin-top:4px}\n"
328
+ ".foot{display:flex;justify-content:flex-end;margin-top:24px;padding-top:12px;"
329
+ "border-top:1px solid var(--hair)}\n"
330
+ '@media (prefers-reduced-motion:no-preference){#state[data-shown^="true"]::before{animation:breathe 2.4s '
331
+ "ease-in-out infinite}}\n"
332
+ "@keyframes breathe{50%{opacity:.3}}\n"
333
+ "@media (min-width:880px){body{padding:56px 32px 48px}h1{font-size:36px;line-height:44px}"
334
+ "form[action='/check'] button,form[action='/relink'] button{width:auto}"
335
+ ".more{grid-template-columns:1fr 1fr;gap:24px;margin-top:48px}.panel{padding:24px}"
336
+ "form[action='/typed']{grid-template-columns:1fr 1fr}form[action='/typed'] button{grid-column:1/-1}}\n"
337
+ # The card «Este PC» of placement A (controls design, round 2 of 2026-10-01, its separate style block
338
+ # without placement B's rules): the resume in the filled pair of «Comprobar ahora», the paused watcher line
339
+ # in the refused pair with a two-bar mark, the switch of the start with Windows under a hairline.
340
+ "form[action='/resume'] button{background:var(--brand);color:var(--on-brand)}\n"
341
+ "#watch[data-paused]{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-radius:12px;"
342
+ "background:var(--danger-bg);color:var(--on-danger-bg);font-weight:500}\n"
343
+ '#watch[data-paused]::before{content:"";flex:none;width:10px;height:12px;margin-top:4px;'
344
+ "border:solid currentColor;border-width:0 3px}\n"
345
+ ".switch{display:flex;gap:12px;align-items:flex-start;font-weight:400;cursor:pointer}\n"
346
+ ".switch input{-webkit-appearance:none;appearance:none;flex:none;width:52px;height:32px;margin:0;padding:0;"
347
+ "border:2px solid var(--ink2);border-radius:999px;background:radial-gradient(circle at 14px 50%,var(--ink2) "
348
+ "8px,transparent 8.5px) var(--tint);cursor:pointer}\n"
349
+ ".switch input:checked{border-color:var(--brand);background:radial-gradient(circle at 34px 50%,"
350
+ "var(--on-brand) 8px,transparent 8.5px) var(--brand)}\n"
351
+ ".switch input:focus{border-color:var(--ink2);box-shadow:none}\n"
352
+ ".switch input:checked:focus{border-color:var(--brand)}\n"
353
+ ".switch input:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
354
+ ".sw-text{display:grid;gap:2px;padding-top:6px}\n"
355
+ ".sw-label{font-weight:500;color:var(--ink)}\n"
356
+ ".sw-help{color:var(--ink2)}\n"
357
+ ".pc{display:grid;gap:16px;max-width:40rem;margin:24px 0 0}\n"
358
+ ".pc h2{margin:0}\n"
359
+ ".pc form{justify-self:start}\n"
360
+ ".pc .switch{padding-top:16px;border-top:1px solid var(--hair)}\n"
361
+ # The card «Actividad» of form A (log design, round 2 of 2026-10-01, its separate style block without
362
+ # candidate B's rules): a list of 224 px that scrolls inside the card, each line its time in tabular figures
363
+ # beside its text, a failure's text in the danger colour, the focus ring of the page's buttons.
364
+ ".log-card{max-width:40rem;margin:16px 0 0}\n"
365
+ ".log-card h2{margin:0 0 4px}\n"
366
+ ".log{max-height:224px;overflow-y:auto;overscroll-behavior:contain;border-radius:12px}\n"
367
+ ".log:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
368
+ ".log ol{margin:0;padding:0;list-style:none}\n"
369
+ ".log li{display:grid;grid-template-columns:max-content minmax(0,1fr);column-gap:16px;padding:4px 0;"
370
+ "font-size:14px;line-height:20px}\n"
371
+ ".log time{color:var(--ink2);font-variant-numeric:tabular-nums}\n"
372
+ ".log .warn{color:var(--danger)}\n")
373
+ # The design sheet of 2026-10-01, round 2: its rules as drawn, after every rule above as its own block was, with the
374
+ # seat's corrections after its review: the sponsored QR's side follows its column, the watcher line keeps its 40rem
375
+ # unless paused, ten explicit rows before the last, the fold's paragraphs capped at 71ch, and the four moves'
376
+ # keyframes inside the reduced-motion guard with the rules that run them.
377
+ # Width: 85 % of the window at every desktop width, never narrower than 1040 px, no upper cap; the code's column grows
378
+ # with the window and the code with it, up to 480 px. Column 1 is one measure: two tracks and a 24 px gutter, every
379
+ # block spans both and the boxes lose their 40rem cap, while the two paragraphs keep theirs (.intro 46ch, .scan 40ch).
380
+ # From a 1600 px window «Este PC» and «Actividad» share one row, a track each, as the two panels of the fold do; dense
381
+ # placement draws the log card beside «Este PC» while the markup keeps its order, so the narrow page is unchanged.
382
+ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
383
+ ".link:has(.key-card),.link:has(.side){display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr) "
384
+ "64px clamp(340px,34vw,522px);grid-template-rows:repeat(10,auto) 1fr;grid-auto-flow:row dense;"
385
+ "column-gap:0}\n"
386
+ ".link>*{grid-column:1/4}\n"
387
+ ".link>.key-card,.link>.side{grid-column:5;grid-row:1/-1;align-self:start;margin:0}\n"
388
+ ".link:has(.key-card)>:is(#state,#watch[data-paused],.pc,.log-card),"
389
+ ".link:has(.side)>:is(#state,#watch[data-paused],.pc,.log-card){max-width:none}\n"
390
+ ".key-card .qr{max-width:480px}}\n"
391
+ "@media (min-width:1600px){.link:has(>.pc):has(>.log-card)>.pc{grid-column:1;align-content:start}\n"
392
+ ".link:has(>.pc):has(>.log-card)>.log-card{grid-column:3;margin-top:24px}}\n"
393
+ # The code is shown; the key sits under the mask until «Mostrar el código» (the script sets data-shown).
394
+ ".key{justify-items:center;text-align:center}\n"
395
+ ".key-val{display:grid;place-items:center;min-height:32px}\n"
396
+ ".key-val>*{grid-area:1/1}\n"
397
+ '.key[data-shown="false"] .code{visibility:hidden}\n'
398
+ '.key[data-shown="true"] .mask{visibility:hidden}\n'
399
+ ".mask{display:flex;align-items:flex-end;gap:10px}\n"
400
+ ".mask .grp{display:flex;align-items:flex-end;gap:2px}\n"
401
+ ".mask svg{display:block;flex:none}\n"
402
+ # Each figure its own move, once every 30 s, for as long as the page is open; still when the system asks
403
+ # for less motion.
404
+ "@media (prefers-reduced-motion:no-preference){\n"
405
+ ".px{transform-origin:50% 100%}\n"
406
+ ".px:nth-child(1){animation:px-archer 30s steps(1,end) infinite}\n"
407
+ ".px:nth-child(2){animation:px-knight 30s steps(1,end) infinite}\n"
408
+ ".px:nth-child(3){animation:px-mage 30s steps(1,end) infinite}\n"
409
+ ".px:nth-child(4){animation:px-creature 30s steps(1,end) infinite}\n"
410
+ "@keyframes px-archer{0%{transform:none}2%{transform:scaleX(-1)}5%,100%{transform:none}}\n"
411
+ "@keyframes px-knight{0%{transform:none}5%{transform:translateX(3px)}6.5%{transform:translateX(6px)}"
412
+ "8%{transform:translateX(3px)}9.5%,100%{transform:none}}\n"
413
+ "@keyframes px-mage{0%{transform:none}10%{transform:translateY(-3px)}11.5%{transform:translateY(-6px)}"
414
+ "16%{transform:translateY(-3px)}17.5%,100%{transform:none}}\n"
415
+ "@keyframes px-creature{0%{transform:none}18%{transform:translate(3px,-6px)}"
416
+ "19%{transform:translate(3px,-3px)}20%{transform:translate(0,-6px)}21%{transform:translate(0,-3px)}"
417
+ "22%{transform:translate(-3px,-6px)}23%{transform:translate(-3px,-3px)}24%,100%{transform:none}}}\n"
418
+ # The typed link and «Olvidar este PC» in one panel, folded.
419
+ ".fold{margin-top:32px;border-radius:16px;background:var(--card);border:1px solid var(--hair)}\n"
420
+ ".fold>summary{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:56px;"
421
+ "padding:16px 20px;border-radius:15px;list-style:none;cursor:pointer;font-weight:500;"
422
+ "transition:box-shadow 160ms ease}\n"
423
+ ".fold>summary::-webkit-details-marker{display:none}\n"
424
+ '.fold>summary::after{content:"";flex:none;width:8px;height:8px;margin:-4px 4px 0 0;border:solid '
425
+ "var(--ink2);border-width:0 2px 2px 0;transform:rotate(45deg)}\n"
426
+ ".fold[open]>summary::after{margin-top:4px;transform:rotate(-135deg)}\n"
427
+ "@media (prefers-reduced-motion:no-preference){.fold>summary::after{transition:transform 200ms "
428
+ "cubic-bezier(.23,1,.32,1),margin 200ms cubic-bezier(.23,1,.32,1)}}\n"
429
+ ".fold>summary:hover{box-shadow:inset 0 0 0 999px var(--hover)}\n"
430
+ ".fold>summary:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
431
+ ".fold[open]>summary{border-radius:15px 15px 0 0}\n"
432
+ ".fold .more{margin:0;padding:0 20px 20px;gap:0;border-top:1px solid var(--hair)}\n"
433
+ ".fold .panel{padding:20px 0 0;border:0;border-radius:0;background:none}\n"
434
+ ".fold .panel+.panel{margin-top:20px;border-top:1px solid var(--hair)}\n"
435
+ ".fold .panel p{max-width:71ch}\n"
436
+ "@media (min-width:880px){.fold{margin-top:48px}.fold>summary{padding:16px 24px}.fold .more{padding:0 24px "
437
+ "24px;gap:0 48px}.fold .panel{padding:24px 0 0}.fold .panel+.panel{margin:0;padding-left:48px;border-top:0;"
438
+ "border-left:1px solid var(--hair)}}\n"
439
+ # The program's name in the title row, and the sentence it took the place of under it.
440
+ ".tagline{margin:0 0 16px;font-size:16px;line-height:24px;font-weight:500;color:var(--ink)}\n"
441
+ "@media (min-width:880px){.tagline{font-size:18px;line-height:28px}}\n"
442
+ # The creator and the repository at the foot, «Salir» stays right.
443
+ ".foot{justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px 24px}\n"
444
+ ".credit{margin:0;font-size:14px;line-height:20px;color:var(--ink2)}\n"
445
+ ".credit b{font-weight:600;color:var(--ink)}\n"
446
+ ".credit a{color:var(--brand);text-decoration:underline;text-decoration-thickness:1px;"
447
+ "text-underline-offset:3px;border-radius:4px}\n"
448
+ ".credit a:hover{text-decoration-thickness:2px}\n"
449
+ ".credit a:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
450
+ # The linked page keeps its right column: a QR to pendiapp.com in a pixel arcade frame of its own, the four
451
+ # figures under it, and why the pairing code is gone. The QR is dark on light in both themes; from 880 px
452
+ # its side follows the column, so the frame never runs past the page.
453
+ ".side{display:grid;gap:16px;margin:32px 0 0}\n"
454
+ ".sponsor{display:grid;justify-items:center;gap:16px;margin:0;padding:20px;border-radius:16px;"
455
+ "background:var(--card);border:1px solid var(--hair);box-shadow:var(--shadow)}\n"
456
+ ".sponsor-by{position:relative;z-index:1;margin:0 0 -32px;padding:6px 18px 9px;background:#8E3D96;"
457
+ 'color:#FFFFFF;font:600 12px/16px ui-monospace,"Cascadia Mono",Consolas,monospace;letter-spacing:.02em;'
458
+ "clip-path:polygon(0 0,100% 0,100% 4px,calc(100% - 4px) 4px,calc(100% - 4px) calc(100% - 4px),"
459
+ "100% calc(100% - 4px),100% 100%,0 100%,0 calc(100% - 4px),4px calc(100% - 4px),4px 4px,0 4px);"
460
+ "box-shadow:inset 0 -3px 0 #40277C}\n"
461
+ ".arcade{position:relative;padding:28px 20px 20px;background:#6D28D9;box-shadow:inset 4px 4px 0 #C3B1F7,"
462
+ "inset -4px -4px 0 #40277C;clip-path:polygon(8px 0,calc(100% - 8px) 0,calc(100% - 8px) 4px,"
463
+ "calc(100% - 4px) 4px,calc(100% - 4px) 8px,100% 8px,100% calc(100% - 8px),calc(100% - 4px) "
464
+ "calc(100% - 8px),calc(100% - 4px) calc(100% - 4px),calc(100% - 8px) calc(100% - 4px),calc(100% - 8px) "
465
+ "100%,8px 100%,8px calc(100% - 4px),4px calc(100% - 4px),4px calc(100% - 8px),0 calc(100% - 8px),0 8px,"
466
+ "4px 8px,4px 4px,8px 4px)}\n"
467
+ ".arcade i{position:absolute;width:8px;height:8px;background:#FBBE3C;"
468
+ "box-shadow:inset -2px -2px 0 #A5510B}\n"
469
+ ".arcade i:nth-of-type(1){top:6px;left:6px}.arcade i:nth-of-type(2){top:6px;right:6px}"
470
+ ".arcade i:nth-of-type(3){bottom:6px;left:6px}.arcade i:nth-of-type(4){bottom:6px;right:6px}\n"
471
+ ".qr2{display:block;width:264px;height:264px;outline:4px solid var(--px-line)}\n"
472
+ "@media (max-width:399px){.qr2{width:231px;height:231px}}\n"
473
+ "@media (min-width:880px){.qr2{width:min(330px,calc(clamp(340px,34vw,522px) - 82px));"
474
+ "height:min(330px,calc(clamp(340px,34vw,522px) - 82px))}}\n"
475
+ ".sponsor-cap{margin:0;font-size:14px;line-height:20px;color:var(--ink2)}\n"
476
+ ".side .note{margin:0}\n")
477
+ # Polls the state; reloads when what the page shows changes, the watcher's pause included (data-paused, served only
478
+ # beside a watcher), so a second tab follows a pause or a resume made in another; the start with Windows as well, on
479
+ # a page that draws the switch only, against the switch as it was rendered (defaultChecked, from enabled(), the read
480
+ # /state answers, so a reload cannot loop, and a poll landing between a change and its post's answer cannot reload
481
+ # the page under the post); the language too, against the one the page was drawn in (<html lang>), so a second tab
482
+ # or another browser turns to a language chosen with the switch; says so when the program is gone.
483
+ # While the program is gone the state line's data-shown is a value no style rule names, so the look of what the page
484
+ # showed (the linked page's check mark) never sits beside the closed sentence; an answer puts the load value back.
485
+ _SCRIPT = ("const s=document.getElementById('state');const w=document.getElementById('watch');"
486
+ "const shown=s.dataset.shown;const paused=s.dataset.paused;const lang=document.documentElement.lang;"
487
+ "const sw=document.querySelector('input[name=autostart]');"
488
+ # The card «Actividad»'s list is a tab stop only while its lines are taller than its box, read at load and
489
+ # after each insert (the design review's open item 3: a stop on a list that does not scroll is dead).
490
+ "const g=document.querySelector('.log');function tabStop(){if(g.scrollHeight>g.clientHeight)"
491
+ "g.setAttribute('tabindex','0');else g.removeAttribute('tabindex');}if(g)tabStop();"
492
+ "setInterval(()=>fetch('/state').then(r=>r.json()).then(j=>{s.textContent=j.text;s.dataset.shown=shown;"
493
+ "if(w&&j.watchText)w.textContent=j.watchText;"
494
+ "if(String(j.showCode)+String(j.relinkOffered)!==shown||(paused!==undefined&&String(j.paused)!==paused)"
495
+ "||j.lang!==lang||(sw&&j.autostart!==undefined&&String(j.autostart)!==String(sw.defaultChecked)))"
496
+ "location.reload();"
497
+ # The log's lines above the highest seq the card has drawn go on top, oldest first so the newest ends
498
+ # first, built as elements with their text (never as markup); the card keeps the last 50, as the ring
499
+ # does. An answer with no log changes nothing.
500
+ "if(g&&j.log){const o=g.firstElementChild;let last=Number(g.dataset.seq);"
501
+ "for(const l of j.log){if(l.seq<=last)continue;const li=document.createElement('li');"
502
+ "const t=document.createElement('time');t.setAttribute('datetime',l.time);t.textContent=l.time;"
503
+ "const x=document.createElement('span');if(l.warn)x.className='warn';x.textContent=l.text;"
504
+ "li.append(t,x);o.insertBefore(li,o.firstChild);last=l.seq;}g.dataset.seq=String(last);"
505
+ "while(o.children.length>50)o.lastElementChild.remove();tabStop();}})"
506
+ ".catch(()=>{s.textContent=s.dataset.closed;s.dataset.shown='closed';}),5000);"
507
+ # The key shows for a minute: the reveal sets data-shown on the key, which swaps the mask for the code, and
508
+ # flips its own label; 60 s after a show the key is masked again; one timer, cleared on every press, so a
509
+ # hide by hand leaves none running. The sheet's function is named showKey here, since the script's first
510
+ # line already declares a const named shown.
511
+ "const k=document.querySelector('.key'),r=document.querySelector('.reveal');let hide;"
512
+ "function showKey(on){k.dataset.shown=String(on);r.textContent=on?r.dataset.hide:r.dataset.show;"
513
+ "clearTimeout(hide);if(on)hide=setTimeout(()=>showKey(false),60000);}"
514
+ "if(r)r.addEventListener('click',()=>showKey(k.dataset.shown!=='true'));"
515
+ # The theme button (pendiapp.com's assets/theme.js): the choice goes to data-theme and to localStorage,
516
+ # and it is posted to /theme, which keeps it in config.json: the page's port changes on every run, so
517
+ # its localStorage is a new origin each time. The server's value, served as data-theme, wins on the next
518
+ # page: localStorage is the fallback only while config.json holds no theme. A post whose write fails is
519
+ # answered as a failed save is (303 to the page, which says the file could not be written), and the
520
+ # choice then lasts until the next page, which keeps the config's theme.
521
+ "(function(){var btn=document.getElementById('theme-toggle');if(!btn)return;"
522
+ "function current(){var t=document.documentElement.getAttribute('data-theme');"
523
+ "if(t==='light'||t==='dark')return t;return matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}"
524
+ "function reflect(){btn.setAttribute('aria-pressed',String(current()==='dark'));}reflect();"
525
+ "btn.addEventListener('click',function(){var next=current()==='dark'?'light':'dark';"
526
+ "document.documentElement.setAttribute('data-theme',next);"
527
+ "try{localStorage.setItem('pendi-theme',next);}catch(e){}reflect();"
528
+ "var f=document.querySelector('input[name=token]');"
529
+ "if(f)fetch('/theme',{method:'POST',body:new URLSearchParams({token:f.value,theme:next})})"
530
+ ".catch(function(){});});})();"
531
+ # The switch of the start with Windows posts its change as the theme button posts, then the page is read
532
+ # again, so the switch shows what the registry holds; a post that fails writes nothing, so the switch is put
533
+ # back as it was, nothing is reloaded, and the poll says the program is gone.
534
+ "(function(){var sw=document.querySelector('input[name=autostart]');if(!sw)return;"
535
+ "sw.addEventListener('change',function(){var f=document.querySelector('input[name=token]');"
536
+ "fetch('/autostart',{method:'POST',body:new URLSearchParams({token:f.value,on:sw.checked?'1':'0'})})"
537
+ ".then(function(){location.reload();}).catch(function(){sw.checked=!sw.checked;});});})();")
538
+ # Read in <head> before the first paint, so a stored theme choice never flashes the other theme (pendiapp.com's
539
+ # index.html head script). The choice kept in config.json, served as data-theme on <html>, wins: localStorage is
540
+ # read only when the page came with none.
541
+ _THEME_READ = ("(function(){try{var e=document.documentElement;if(e.hasAttribute('data-theme'))return;"
542
+ "var t=localStorage.getItem('pendi-theme');"
543
+ "if(t==='light'||t==='dark')e.setAttribute('data-theme',t);}catch(x){}})();")
544
+ # The theme button's two icons, copied from pendiapp.com's header; the style shows the one of the other theme.
545
+ _THEME_ICONS = ('<svg class="moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
546
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
547
+ '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>'
548
+ '<svg class="sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
549
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
550
+ '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 '
551
+ '12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>')
552
+
553
+ # Four original 16 by 16 pixel figures under the key (an archer, a knight, a mage and a small winged creature), in
554
+ # the app's colours; 'o' is the outline, drawn in the theme's outline colour, and '.' is empty.
555
+ _FIGURE_COLOURS = {"p": "#8E3D96", "d": "#40277C", "w": "#E9DEFB", "v": "#6D28D9", "l": "#C3B1F7", "s": "#A79FC2",
556
+ "h": "#ECE8F6", "a": "#A5510B", "y": "#FBBE3C", "b": "#2A5AD6", "c": "#6BA6FB", "q": "#D8A9E0"}
557
+ _FIGURES = (
558
+ ("....oooo........", "...oppppo....a..", "..opppppo....la.", "..oppppppo...l.a", ".oppdddddpo..l.a",
559
+ ".opdwdddwdo..l.a", ".opdddddddo..l.a", ".oppdddddpo..l.a", "..oopppppoo..l.a", ".opovvvvvopwwl.a",
560
+ ".opovvvvvoo..l.a", ".opoaaaaaoo..la.", ".opovvvvvop..a..", "..oovvvvvoo.....", "...odo.odo......",
561
+ "...ooo.ooo......"),
562
+ (".......pp.......", "......ppp.......", "....oooooooo....", "...ohhsssssso...", "...ohssssssso...",
563
+ "...osolooloso...", "...ohssssssso...", "....osssssso....", "..ossssssooooooo", ".ohssssssovvvvvo",
564
+ ".ohssssssovvyvvo", ".odddddddovydyvo", ".ohssssssovvyvvo", "..ossssssoovvvo.", "...oso.oso.ovo..",
565
+ "...ooo.ooo..o..."),
566
+ ("........oo......", ".......ovvo..oo.", "......ovvvo.oqyo", "......ovvvvooyyo", ".....ovvvvvo.oo.",
567
+ "..oollllllllo.a.", "...owwwwwwo...a.", "...owdwwdwo...a.", "...ohhhhhho..wa.", "..oddhhhhddddwa.",
568
+ "..odddhhdddoo.a.", "..oddddddddo..a.", "..oddllllddo..a.", ".oddddddddddo.a.", ".oddddddddddo.a.",
569
+ ".oooooooooooo.a."),
570
+ ("................", "................", "................", "........y.y.....", ".......oooooo...",
571
+ "......occcbbo...", "..o...obbwobo...", ".oco..obbbbbbo..", ".occo.obbboooo..", ".ocllobbbbo.....",
572
+ "..occobbwwbo....", "...oobwwwwbo....", ".o..obwwwwbbo...", ".ob.obwwwwbbo...", "..obobbbbbbbo...",
573
+ "...ooooooooo...."),
574
+ )
575
+
576
+
577
+ def _paths(rows):
578
+ """A pixel drawing's paths: one per colour, each a run of same-colour pixels per row."""
579
+ runs = {}
580
+ for y, row in enumerate(rows):
581
+ x = 0
582
+ while x < len(row):
583
+ if row[x] == ".":
584
+ x += 1
585
+ continue
586
+ start = x
587
+ while x < len(row) and row[x] == row[start]:
588
+ x += 1
589
+ runs.setdefault(row[start], []).append(f"M{start} {y}h{x - start}v1h-{x - start}z")
590
+ return "".join(f'<path class="pl" d="{"".join(d)}"/>' if key == "o" else
591
+ f'<path fill="{_FIGURE_COLOURS[key]}" d="{"".join(d)}"/>' for key, d in runs.items())
592
+
593
+
594
+ def _figure(rows):
595
+ """One figure as an inline svg of 48 px."""
596
+ return (f'<svg class="px" viewBox="0 0 {len(rows[0])} 16" width="48" height="48" shape-rendering="crispEdges">'
597
+ f"{_paths(rows)}</svg>")
598
+
599
+
600
+ # The party holds the four figures and nothing else, so .px:nth-child(1) to (4) are the archer, the knight, the mage
601
+ # and the creature, each with its own move.
602
+ _PARTY = '<div class="party" aria-hidden="true">' + "".join(_figure(rows) for rows in _FIGURES) + "</div>"
603
+
604
+ # The mask over the key: one 8 by 8 head per character, the party's four in small (hood, helm, hat, creature), twelve
605
+ # in three groups of four. The heads are the same whatever the key, so the mask tells nothing of it; a screen reader
606
+ # reads the mask's name, never its drawings.
607
+ _HEADS = (
608
+ ("..oooo..", ".oppppo.", "oppppppo", "opdddddo", "opdwdwdo", "opdddddo", ".oppppo.", "..oooo.."),
609
+ ("...pp...", ".oooooo.", "ohhsssso", "ohssssso", "osooooso", "ohssssso", ".osssso.", "..oooo.."),
610
+ ("...oo...", "..ovvo..", ".ovvvvo.", "ollllllo", ".owwwwo.", ".odwwdo.", ".owwwwo.", "..oooo.."),
611
+ ("..y..y..", ".oooooo.", "obbbbbbo", "obwwbbbo", "obwobbco", "obbbbcco", ".obbbbo.", "..oooo.."),
612
+ )
613
+ _MASK_GROUP = ('<span class="grp">' + "".join('<svg class="mx" viewBox="0 0 8 8" width="16" height="16" '
614
+ f'shape-rendering="crispEdges" aria-hidden="true">{_paths(rows)}</svg>'
615
+ for rows in _HEADS) + "</span>")
616
+
617
+
618
+ # With scripts blocked the reveal does nothing, so the key reads as it did before the mask: the code shown, the mask
619
+ # and the reveal gone. The code's rule repeats the mask's own selector and comes later, so it wins.
620
+ _NOSCRIPT_STYLE = '.key[data-shown="false"] .code{visibility:visible}.mask,.reveal{display:none}'
621
+
622
+
623
+ def _mask(words):
624
+ """The mask over the key, three groups of the four heads, named for a screen reader. `words` are escaped."""
625
+ return f'<span class="mask" role="img" aria-label="{words["mask"]}">{_MASK_GROUP * 3}</span>'
626
+
627
+
628
+ def _sponsor_runs():
629
+ """The sponsored QR's dark modules as one path's runs, one module tall, inside the quiet zone; and the side of
630
+ the whole square, quiet zone included."""
631
+ modules = qr.encode(SPONSOR_ADDRESS.encode("ascii")).modules
632
+ runs = []
633
+ for y, row in enumerate(modules):
634
+ x = 0
635
+ while x < len(row):
636
+ if not row[x]:
637
+ x += 1
638
+ continue
639
+ start = x
640
+ while x < len(row) and row[x]:
641
+ x += 1
642
+ runs.append(f"M{start + qr.QUIET_ZONE} {y + qr.QUIET_ZONE}h{x - start}v1h-{x - start}z")
643
+ return "".join(runs), len(modules) + 2 * qr.QUIET_ZONE
644
+
645
+
646
+ _SPONSOR_RUNS, _SPONSOR_SIDE = _sponsor_runs()
647
+
648
+
649
+ def _sponsor_qr(words):
650
+ """The sponsored QR: square dark modules on a light field, the same in both themes. `words` are escaped."""
651
+ return (f'<svg class="qr2" viewBox="0 0 {_SPONSOR_SIDE} {_SPONSOR_SIDE}" role="img" '
652
+ f'aria-label="{words["sponsor_qr"]}" shape-rendering="crispEdges"><rect width="{_SPONSOR_SIDE}" '
653
+ f'height="{_SPONSOR_SIDE}" fill="#FFFFFF"/><path fill="#1E1533" d="{_SPONSOR_RUNS}"/></svg>')
654
+
655
+
656
+ def language(accept_language):
657
+ """English when the first tag of Accept-Language is English, Spanish otherwise and when absent."""
658
+ first = (accept_language or "").split(",")[0].split(";")[0].strip()
659
+ return "en" if first.split("-")[0].lower() == "en" else "es"
660
+
661
+
662
+ def _report_failure(request, client_address):
663
+ """A request that broke inside the handler is said on stderr by its type only, never with its data;
664
+ a browser that dropped its connection is not a failure of the page."""
665
+ failure = sys.exc_info()[1]
666
+ if not isinstance(failure, ConnectionError):
667
+ _say_failure(failure)
668
+
669
+
670
+ def _say_failure(failure):
671
+ """The one fixed stderr line of a failed request: its type, never its data."""
672
+ print(f" [page] a request failed: {type(failure).__name__}", file=sys.stderr)
673
+
674
+
675
+ def _paused(snapshot):
676
+ """True when the watcher's snapshot says it is paused."""
677
+ return snapshot.get("paused") is True
678
+
679
+
680
+ def _state_word(snapshot, seen):
681
+ """The state line's word, the same for the page and for its poll: linked with the watcher paused reads
682
+ state_linked_paused, every other state its own."""
683
+ if snapshot["state"] == "linked" and seen is not None and _paused(seen):
684
+ return "state_linked_paused"
685
+ return "state_" + snapshot["state"]
686
+
687
+
688
+ def _clock_time(at):
689
+ """A wall time as the page says it, hours and minutes in this PC's zone."""
690
+ return time.strftime("%H:%M", time.localtime(at))
691
+
692
+
693
+ def _log_time(at):
694
+ """A wall time as the log says it, hours, minutes and seconds in this PC's zone."""
695
+ return time.strftime("%H:%M:%S", time.localtime(at))
696
+
697
+
698
+ def _form(action, token, inner):
699
+ return (f'<form method="post" action="/{action}"><input type="hidden" name="token" value="{token}">'
700
+ f"{inner}</form>")
701
+
702
+
703
+ _CURRENT = ' aria-current="true"'
704
+
705
+
706
+ def _switch(words, token, lang):
707
+ """pendiapp.com's language switch: ES then EN, the page's language marked aria-current. The page changes state
708
+ only by a POST with its token, so the switch is one form to /lang that is the group, named «Idioma» or "Language"
709
+ for a screen reader, and each entry is a submit button posting its own value. `words` are escaped already."""
710
+ entries = "".join(f'<button type="submit" name="lang" value="{code}"{_CURRENT if code == lang else ""}>'
711
+ f"{code.upper()}</button>" for code in config.LANGS)
712
+ return (f'<form class="langsw" role="group" aria-label="{words["language"]}" method="post" action="/lang">'
713
+ f'<input type="hidden" name="token" value="{token}">{entries}</form>')
714
+
715
+
716
+ class PairingPage:
717
+ def __init__(self, state, watch=None, on_quit=None, on_pause=None, on_resume=None, autostart=None, events=None):
718
+ """`watch` answers the watcher's snapshot; without it the page shows no watcher line. `on_quit` stops
719
+ the program; without it the page shows no quit button and /quit is no route. `on_pause` and `on_resume`
720
+ pause and resume the watcher; without them /pause and /resume are no routes. `autostart` is the start
721
+ with Windows; without it, or where it is not available, /autostart is no route and /state says nothing of
722
+ it. Beside a watcher the page draws the card «Este PC» after the watcher line: the pause, or the resume while
723
+ paused, and the switch of the start with Windows where it is available. `events` answers the watcher's
724
+ events, oldest first; with it /state carries the log's lines and the page draws them in the card «Actividad»,
725
+ without it no log key and no card."""
726
+ self.state = state
727
+ self.watch = watch
728
+ self.on_quit = on_quit
729
+ self.on_pause, self.on_resume = on_pause, on_resume
730
+ self.autostart = autostart if autostart is not None and autostart.available else None
731
+ self.events = events
732
+ self.token = secrets.token_urlsafe(32)
733
+ self.server = None
734
+ self.port = None
735
+
736
+ def start(self):
737
+ """Serve on 127.0.0.1 on a port the system assigns; the page's address."""
738
+ self.server = ThreadingHTTPServer((ADDRESS, 0), _handler(self))
739
+ self.server.daemon_threads = True
740
+ self.server.handle_error = _report_failure
741
+ self.port = self.server.server_address[1]
742
+ threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True).start()
743
+ return f"http://{ADDRESS}:{self.port}/"
744
+
745
+ def close(self):
746
+ if self.server is not None:
747
+ self.server.shutdown()
748
+ self.server.server_close()
749
+
750
+ def host_allowed(self, host):
751
+ return (host or "").strip().lower() in (f"127.0.0.1:{self.port}", f"localhost:{self.port}")
752
+
753
+ def language_of(self, accept_language):
754
+ """The page's language, the one resolver of every page, /state and the stopped page: the switch's choice
755
+ kept in the config file when there is one, else the browser's first Accept-Language tag (language())."""
756
+ return self.state.lang() or language(accept_language)
757
+
758
+ def watch_text(self, lang):
759
+ """The watcher's line in `lang`: waiting or connected, then the phase while connected once one is read,
760
+ then the last alert and its time, then the last ping's result and its time when a ping was made; None when
761
+ no watcher runs beside the page. It names no game, no maker and no product."""
762
+ if self.watch is None:
763
+ return None
764
+ return self._watch_text(self.watch(), lang)
765
+
766
+ @staticmethod
767
+ def _watch_text(snapshot, lang):
768
+ """watch_text on one snapshot: paused, the paused sentence alone."""
769
+ words = WORDS[lang]
770
+ if _paused(snapshot):
771
+ return words["watch_paused"]
772
+ connected = snapshot.get("client") == "connected"
773
+ parts = [words["watch_connected" if connected else "watch_waiting"]]
774
+ # Connected with nothing read yet (None) prints no phase clause; the client's own "None" reads phase_none.
775
+ if connected and snapshot.get("phase") is not None:
776
+ phase = PHASE_WORDS.get(snapshot["phase"], "phase_other")
777
+ parts.append(words["watch_phase"].format(phase=words[phase]))
778
+ if snapshot.get("alert") in ("loading", "queue", "started") and snapshot.get("at") is not None:
779
+ parts.append(words["watch_last"].format(what=words["alert_" + snapshot["alert"]],
780
+ time=_clock_time(snapshot["at"])))
781
+ if snapshot.get("pingResult") in PING_RESULTS and snapshot.get("pingAt") is not None:
782
+ parts.append(words["watch_ping"].format(result=words["ping_" + snapshot["pingResult"]],
783
+ time=_clock_time(snapshot["pingAt"])))
784
+ return " ".join(parts)
785
+
786
+ @staticmethod
787
+ def _log_lines(events, lang):
788
+ """The log's lines in `lang`, oldest first, each {seq, time, text, warn}. A phase whose word is the word of
789
+ the last phase line kept is dropped, so the three phases that end a match give one line, until a line of
790
+ LOG_PHASE_BREAKS is kept: the same phase after a reconnect or a resume is shown again. A kind or a ping
791
+ result the page does not know is dropped."""
792
+ words, lines, last_phase = WORDS[lang], [], None
793
+ for seq, at, kind, detail in events:
794
+ if kind == "phase":
795
+ text = words[PHASE_WORDS.get(detail, "phase_other")] + "."
796
+ if text == last_phase:
797
+ continue
798
+ last_phase = text
799
+ elif kind == "ping" and detail in PING_RESULTS:
800
+ text = words["log_ping"].format(result=words["ping_" + detail])
801
+ elif kind in LOG_WORDS:
802
+ text = words[LOG_WORDS[kind]]
803
+ else:
804
+ continue
805
+ lines.append({"seq": seq, "time": _log_time(at), "text": text,
806
+ "warn": kind in LOG_WARNS or (kind == "ping" and detail != "sent")})
807
+ if kind in LOG_PHASE_BREAKS:
808
+ last_phase = None
809
+ return lines
810
+
811
+ def _log_card(self, words, lang):
812
+ """The card «Actividad» (form A, frames A1 to A6): its title, its helper, and the list named by both, the
813
+ newest line first, carrying the highest seq it draws for the poll (0 before any line). `words` are escaped
814
+ already. Rendered with no tab stop: the script sets one while the list scrolls."""
815
+ lines = self._log_lines(self.events(), lang)
816
+ items = []
817
+ for line in reversed(lines):
818
+ span = '<span class="warn">' if line["warn"] else "<span>"
819
+ items.append(f'<li><time datetime="{line["time"]}">{line["time"]}</time>{span}{html.escape(line["text"])}'
820
+ f"</span></li>")
821
+ last = lines[-1]["seq"] if lines else 0
822
+ return (f'<div class="panel log-card"><h2 id="log-title">{words["log_title"]}</h2><p id="log-help">'
823
+ f'{words["log_help"]}</p><div class="log" role="region" aria-labelledby="log-title" '
824
+ f'aria-describedby="log-help" data-seq="{last}"><ol>{"".join(items)}</ol></div></div>')
825
+
826
+ def state_json(self, lang):
827
+ """The state for the page's poll: the state line's sentence, and beside a watcher its line and whether it
828
+ is paused, and where the start with Windows is available whether it is on."""
829
+ snapshot = self.state.snapshot()
830
+ seen = self.watch() if self.watch is not None else None
831
+ answer = {**snapshot, "text": WORDS[lang][_state_word(snapshot, seen)], "lang": lang}
832
+ if seen is not None:
833
+ answer["watchText"] = self._watch_text(seen, lang)
834
+ answer["paused"] = _paused(seen)
835
+ if self.autostart is not None:
836
+ answer["autostart"] = self.autostart.enabled()
837
+ if self.events is not None:
838
+ answer["log"] = self._log_lines(self.events(), lang)
839
+ return answer
840
+
841
+ def render(self, lang):
842
+ words = {key: html.escape(value) for key, value in WORDS[lang].items()}
843
+ snapshot = self.state.snapshot()
844
+ seen = self.watch() if self.watch is not None else None
845
+ secret = self.state.secret_for_display() if snapshot["showCode"] else None
846
+ token = html.escape(self.token)
847
+ # Beside a watcher the state line carries the pause it was rendered with, which the poll compares.
848
+ paused = "" if seen is None else f' data-paused="{str(_paused(seen)).lower()}"'
849
+ # What this is and what to do, with the QR card beside it on a wide window; the two other roads in one fold
850
+ # under it; the credit and quit at the foot. The title row reads the program's name, its sentence under it.
851
+ link = [f'<div class="title-row"><img alt="" width="32" height="32" src="{ICON_URI}"><h1>{NAME}</h1>'
852
+ f"</div>", f'<p class="tagline">{words["title"]}</p>', f'<p class="intro">{words["intro"]}</p>',
853
+ f'<p id="state" role="status" data-shown="{str(snapshot["showCode"]).lower()}'
854
+ f'{str(snapshot["relinkOffered"]).lower()}"{paused} data-closed="{words["state_closed"]}">'
855
+ f'{words[_state_word(snapshot, seen)]}</p>']
856
+ if snapshot["configFailed"]:
857
+ link.append(f'<p class="note warn">{words["config_failed"]}</p>')
858
+ if seen is not None:
859
+ # While paused the watcher line is the paused block (data-paused); the card follows it.
860
+ held = ' data-paused=""' if _paused(seen) else ""
861
+ link += [f'<p id="watch" role="status"{held}>{html.escape(self._watch_text(seen, lang))}</p>',
862
+ self._card(words, token, _paused(seen))]
863
+ # The card «Actividad» follows the card «Este PC»; on the page that still shows the code (frame A6) and on the
864
+ # page that offers the relink it ends the first column instead, so neither the code nor the relink control
865
+ # moves. No card without the events.
866
+ log_card = None if self.events is None else self._log_card(words, lang)
867
+ last = secret is not None or snapshot["relinkOffered"]
868
+ if log_card is not None and not last:
869
+ link.append(log_card)
870
+ if secret is not None:
871
+ modules = qr.encode(qr.pairing_address(secret).encode("ascii")).modules
872
+ drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="scan")
873
+ if drawn is None: # the scene is drawn for version 3 only
874
+ drawn = qr.svg(modules, labelledby="scan")
875
+ link += [f'<p class="scan" id="scan">{words["scan"]}</p>',
876
+ # The code is shown (owner 2026-10-01); the key's text sits under the mask until its person
877
+ # presses the reveal, a real button whose label says what a press does; _SCRIPT masks the key
878
+ # again 60 s after a show.
879
+ f'<figure class="key-card">{drawn}<p class="key" data-shown="false"><span class="key-label">'
880
+ f'{words["code_label"]}</span> <span class="key-val">{_mask(words)}<span class="code" id="code">'
881
+ f'{codes.display(secret)}</span></span></p><button type="button" class="reveal" '
882
+ f'aria-controls="code" data-show="{words["show_code"]}" data-hide="{words["hide_code"]}">'
883
+ f'{words["show_code"]}</button>{_PARTY}</figure>',
884
+ _form("check", token, f'<button type="submit">{words["check"]}</button>')]
885
+ if snapshot["buttonRefused"]:
886
+ link.append(f'<p class="note">{words["button_wait"]}</p>')
887
+ if snapshot["relinkOffered"]:
888
+ link.append(_form("relink", token, f'<button type="submit">{words["relink"]}</button>'))
889
+ if log_card is not None and last:
890
+ link.append(log_card)
891
+ if snapshot["linked"]:
892
+ # The linked page keeps its right column: the sponsored QR with the party under it and, unless the relink
893
+ # is offered (its state line says the account refuses this PC), why the pairing code is gone.
894
+ note = "" if snapshot["relinkOffered"] else f'<p class="note">{words["sponsor_note"]}</p>'
895
+ link.append(f'<aside class="side"><figure class="sponsor"><figcaption class="sponsor-by">'
896
+ f'{words["sponsor_by"]}</figcaption><div class="arcade"><i></i><i></i><i></i><i></i>'
897
+ f'{_sponsor_qr(words)}</div><p class="sponsor-cap">{words["sponsor_cap"]}</p>{_PARTY}'
898
+ f"</figure>{note}</aside>")
899
+ typed = [f"<h2>{words['typed_title']}</h2>",
900
+ _form("typed", token, f'<label>{words["link_id_label"]} <input name="linkId" maxlength="32" '
901
+ f'autocomplete="off"></label><label>{words["secret_label"]} <input '
902
+ f'name="secret" maxlength="32" autocomplete="off"></label>'
903
+ f'<button type="submit">{words["save"]}</button>')]
904
+ if snapshot["typedRefused"]:
905
+ typed.append(f'<p class="note warn">{words["typed_refused"]}</p>')
906
+ forget = [f"<h2>{words['forget']}</h2>", f"<p>{words['forget_sentence']}</p>",
907
+ _form("forget", token, f'<button type="submit">{words["forget"]}</button>')]
908
+ quit_button = f'<button type="submit">{words["quit"]}</button>'
909
+ quit_form = "" if self.on_quit is None else _form("quit", token, quit_button)
910
+ foot = (f'<footer class="foot"><p class="credit">{words["credit"]} <b>{CREATOR}</b> · <a href="{REPOSITORY}" '
911
+ f'target="_blank" rel="noopener noreferrer">{REPOSITORY.removeprefix("https://")}</a></p>{quit_form}'
912
+ "</footer>")
913
+ # The typed link and the forget in one fold, open when the typed values were refused so the note shows.
914
+ fold = " open" if snapshot["typedRefused"] else ""
915
+ bar = (f'<header class="bar">{_switch(words, token, lang)}<button id="theme-toggle" class="icon-btn" '
916
+ f'type="button" aria-pressed="false" '
917
+ f'aria-label="{words["theme_toggle"]}">{_THEME_ICONS}</button></header>')
918
+ theme = self.state.theme()
919
+ kept = "" if theme is None else f' data-theme="{html.escape(theme)}"'
920
+ body = (f'<main class="page">{bar}<section class="link">{"".join(link)}</section><details class="fold"{fold}>'
921
+ f'<summary>{words["fold"]}</summary><section class="more"><div class="panel">{"".join(typed)}</div>'
922
+ f'<div class="panel">{"".join(forget)}</div></section></details>{foot}</main>')
923
+ return (f'<!doctype html><html lang="{lang}"{kept}><head><meta charset="utf-8"><meta name="viewport" '
924
+ f'content="width=device-width, initial-scale=1"><title>{NAME}</title>{_TAB_ICON}<style>{_STYLE}'
925
+ f"</style><noscript><style>{_NOSCRIPT_STYLE}</style></noscript><script>{_THEME_READ}</script></head>"
926
+ f"<body>{body}<script>{_SCRIPT}</script></body></html>")
927
+
928
+ def _card(self, words, token, paused):
929
+ """The card «Este PC» (placement A, frames A1 to A6): the pause form, or the resume form while paused, then,
930
+ where the start with Windows is available, its switch under a hairline, checked when the Run value exists.
931
+ `words` are escaped already."""
932
+ action = "resume" if paused else "pause"
933
+ inner = [f'<h2 id="pc-title">{words["this_pc"]}</h2>',
934
+ _form(action, token, f'<button type="submit">{words[action]}</button>')]
935
+ if self.autostart is not None:
936
+ checked = " checked" if self.autostart.enabled() else ""
937
+ inner.append(f'<label class="switch"><input type="checkbox" role="switch" name="autostart" '
938
+ f'aria-labelledby="sw-label" aria-describedby="sw-help"{checked}><span class="sw-text">'
939
+ f'<span class="sw-label" id="sw-label">{words["autostart_label"]}</span><span class="sw-help" '
940
+ f'id="sw-help">{words["autostart_help"]}</span></span></label>')
941
+ return f'<div class="panel pc" role="group" aria-labelledby="pc-title">{"".join(inner)}</div>'
942
+
943
+ def render_stopped(self, lang):
944
+ """The one small page /quit answers: the program stopped, and how to start it again."""
945
+ words = {key: html.escape(value) for key, value in WORDS[lang].items()}
946
+ theme = self.state.theme()
947
+ kept = "" if theme is None else f' data-theme="{html.escape(theme)}"'
948
+ return (f'<!doctype html><html lang="{lang}"{kept}><head><meta charset="utf-8"><meta name="viewport" '
949
+ f'content="width=device-width, initial-scale=1"><title>{NAME}</title>{_TAB_ICON}<style>{_STYLE}'
950
+ f'</style><script>{_THEME_READ}</script></head><body><h1>{NAME}</h1><p>{words["stopped"]}</p>'
951
+ f'<p>{words["start_again"]}</p></body></html>')
952
+
953
+ def set_theme(self, choice):
954
+ """/theme: the theme button's choice kept in the config file; "kept", or "refused" for a value that is
955
+ not light, dark or system, which changes nothing, or "failed" for a config file that cannot be replaced,
956
+ said as act() says it and answered as a failed save is."""
957
+ try:
958
+ self.state.set_theme(choice)
959
+ except ValueError:
960
+ return "refused"
961
+ except config.ConfigError as failure:
962
+ _say_failure(failure)
963
+ self.state.config_failed()
964
+ return "failed"
965
+ return "kept"
966
+
967
+ def set_lang(self, choice):
968
+ """/lang: the switch's choice kept in the config file; "kept", or "refused" for a value that is not es or
969
+ en, which changes nothing, or "failed" for a config file that cannot be replaced, said as act() says it."""
970
+ try:
971
+ self.state.set_lang(choice)
972
+ except ValueError:
973
+ return "refused"
974
+ except config.ConfigError as failure:
975
+ _say_failure(failure)
976
+ self.state.config_failed()
977
+ return "failed"
978
+ return "kept"
979
+
980
+ def set_autostart(self, on):
981
+ """/autostart: the start with Windows turned on or off; a registry failure is said on stderr by the
982
+ Autostart and changes nothing, and the page's next state says what holds."""
983
+ if on:
984
+ self.autostart.enable()
985
+ else:
986
+ self.autostart.disable()
987
+
988
+ def act(self, path, form):
989
+ """The route's action; a config file that cannot be read or replaced is said on the page and on
990
+ stderr, and the request is still answered."""
991
+ try:
992
+ if path == "/check":
993
+ self.state.ask()
994
+ elif path == "/typed":
995
+ self.state.typed(form.get("linkId", ""), form.get("secret", ""))
996
+ elif path == "/forget":
997
+ self.state.forget()
998
+ elif path == "/relink":
999
+ self.state.accept_relink()
1000
+ elif path == "/pause":
1001
+ self.on_pause()
1002
+ elif path == "/resume":
1003
+ self.on_resume()
1004
+ except config.ConfigError as failure:
1005
+ _say_failure(failure)
1006
+ self.state.config_failed()
1007
+
1008
+
1009
+ def _handler(page):
1010
+ class Handler(BaseHTTPRequestHandler):
1011
+ server_version = "local"
1012
+ sys_version = ""
1013
+ # A socket that declares a body and sends none is dropped after it, with no answer, and its thread ends.
1014
+ timeout = 10
1015
+
1016
+ def log_message(self, format, *args): # no request line, no body, ever
1017
+ pass
1018
+
1019
+ def end_headers(self):
1020
+ for name, value in FENCE_HEADERS:
1021
+ self.send_header(name, value)
1022
+ super().end_headers()
1023
+
1024
+ def _send(self, status, content_type, text, extra=()):
1025
+ data = text.encode("utf-8")
1026
+ self.send_response(status)
1027
+ for name, value in extra:
1028
+ self.send_header(name, value)
1029
+ self.send_header("Connection", "close") # no client reuses a socket the handler is done with
1030
+ self.send_header("Content-Type", content_type)
1031
+ self.send_header("Content-Length", str(len(data)))
1032
+ self.end_headers()
1033
+ if self.command != "HEAD":
1034
+ self.wfile.write(data)
1035
+
1036
+ def _refuse(self, status):
1037
+ self._send(status, "text/plain; charset=utf-8", {403: "forbidden", 404: "not found", 400: "bad request",
1038
+ 411: "length required", 413: "too large"}[status])
1039
+
1040
+ def do_GET(self):
1041
+ self._get(seen=True)
1042
+
1043
+ def do_HEAD(self):
1044
+ # The fences and the headers of GET, no body; a HEAD is not a person looking at the page.
1045
+ self._get(seen=False)
1046
+
1047
+ def _get(self, seen):
1048
+ if not page.host_allowed(self.headers.get("Host")):
1049
+ return self._refuse(403)
1050
+ path = urllib.parse.urlsplit(self.path).path
1051
+ lang = page.language_of(self.headers.get("Accept-Language"))
1052
+ if seen and path in ("/", "/state"):
1053
+ page.state.page_seen()
1054
+ if path == "/":
1055
+ return self._send(200, "text/html; charset=utf-8", page.render(lang))
1056
+ if path == "/state":
1057
+ return self._send(200, "application/json", json.dumps(page.state_json(lang)))
1058
+ return self._refuse(404)
1059
+
1060
+ def _drain(self):
1061
+ """What the socket holds, up to DRAIN_BYTES, for a body with no usable length: read while bytes
1062
+ come within DRAIN_WAIT, so no answer leaves bytes unread."""
1063
+ self.connection.settimeout(DRAIN_WAIT)
1064
+ try:
1065
+ drained = 0
1066
+ while drained < DRAIN_BYTES:
1067
+ chunk = self.rfile.read1(DRAIN_BYTES - drained)
1068
+ if not chunk:
1069
+ return
1070
+ drained += len(chunk)
1071
+ except TimeoutError: # nothing more came within DRAIN_WAIT: the drain ends, it did not fail
1072
+ return
1073
+ finally:
1074
+ self.connection.settimeout(self.timeout)
1075
+
1076
+ def do_POST(self):
1077
+ declared = self.headers.get("Content-Length")
1078
+ encoded = self.headers.get("Transfer-Encoding") is not None
1079
+ plain = declared is not None and declared.strip().isascii() and declared.strip().isdigit()
1080
+ length = int(declared) if plain and not encoded else None
1081
+ # Read before any answer, up to DRAIN_BYTES: the declared body, or what the socket holds when there is
1082
+ # no usable length; a longer declaration leaves the rest unread.
1083
+ if length is None:
1084
+ self._drain()
1085
+ body = self.rfile.read(min(length, DRAIN_BYTES)) if length else b""
1086
+ if not page.host_allowed(self.headers.get("Host")):
1087
+ return self._refuse(403)
1088
+ path = urllib.parse.urlsplit(self.path).path
1089
+ routes = ("/check", "/typed", "/forget", "/relink", "/theme", "/lang")
1090
+ routes += ("/quit",) if page.on_quit is not None else ()
1091
+ routes += ("/pause",) if page.on_pause is not None else ()
1092
+ routes += ("/resume",) if page.on_resume is not None else ()
1093
+ routes += ("/autostart",) if page.autostart is not None else ()
1094
+ if path not in routes:
1095
+ return self._refuse(404)
1096
+ if length is None: # chunked or absent is 411, anything but plain digits 400
1097
+ return self._refuse(411 if encoded or declared is None else 400)
1098
+ if length > MAX_FORM_BYTES:
1099
+ return self._refuse(413)
1100
+ raw = body.decode("utf-8", "replace")
1101
+ try:
1102
+ fields = urllib.parse.parse_qs(raw, keep_blank_values=True, max_num_fields=8) if raw else {}
1103
+ except ValueError: # more fields than any form of the page has
1104
+ return self._refuse(400)
1105
+ form = {key: values[0] for key, values in fields.items()}
1106
+ if not hmac.compare_digest(form.get("token", "").encode(), page.token.encode()):
1107
+ return self._refuse(403)
1108
+ if path == "/quit":
1109
+ # Answered before the stop, so the exit that follows never cuts the answer short; stopped even
1110
+ # when the answer cannot be written, and that failure goes on to the server's handle_error.
1111
+ try:
1112
+ self._send(200, "text/html; charset=utf-8",
1113
+ page.render_stopped(page.language_of(self.headers.get("Accept-Language"))))
1114
+ finally:
1115
+ page.on_quit()
1116
+ return
1117
+ if path == "/theme": # the page's script posts it and reads no page back
1118
+ outcome = page.set_theme(form.get("theme", ""))
1119
+ if outcome == "refused":
1120
+ return self._refuse(400)
1121
+ if outcome == "kept":
1122
+ return self._send(204, "text/plain; charset=utf-8", "")
1123
+ return self._send(303, "text/plain; charset=utf-8", "", (("Location", "/"),))
1124
+ if path == "/lang": # es or en, then the page again, drawn in it; anything else changes nothing
1125
+ if page.set_lang(form.get("lang", "")) == "refused":
1126
+ return self._refuse(400)
1127
+ return self._send(303, "text/plain; charset=utf-8", "", (("Location", "/"),))
1128
+ if path == "/autostart": # on is 1 or 0; anything else changes nothing
1129
+ if form.get("on") not in ("1", "0"):
1130
+ return self._refuse(400)
1131
+ page.set_autostart(form["on"] == "1")
1132
+ return self._send(303, "text/plain; charset=utf-8", "", (("Location", "/"),))
1133
+ page.act(path, form)
1134
+ self._send(303, "text/plain; charset=utf-8", "", (("Location", "/"),))
1135
+
1136
+ return Handler