pendify 0.1.5__tar.gz → 0.1.7__tar.gz

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.
Files changed (29) hide show
  1. {pendify-0.1.5/src/pendify.egg-info → pendify-0.1.7}/PKG-INFO +3 -3
  2. {pendify-0.1.5 → pendify-0.1.7}/README.md +2 -2
  3. {pendify-0.1.5 → pendify-0.1.7}/pyproject.toml +1 -1
  4. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/__main__.py +93 -5
  5. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/page.py +79 -15
  6. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/qr.py +129 -37
  7. pendify-0.1.7/src/pendify/runfile.py +501 -0
  8. {pendify-0.1.5 → pendify-0.1.7/src/pendify.egg-info}/PKG-INFO +3 -3
  9. pendify-0.1.5/src/pendify/runfile.py +0 -246
  10. {pendify-0.1.5 → pendify-0.1.7}/LICENSE +0 -0
  11. {pendify-0.1.5 → pendify-0.1.7}/MANIFEST.in +0 -0
  12. {pendify-0.1.5 → pendify-0.1.7}/setup.cfg +0 -0
  13. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/__init__.py +0 -0
  14. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/alert.py +0 -0
  15. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/autostart.py +0 -0
  16. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/client.py +0 -0
  17. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/codes.py +0 -0
  18. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/config.py +0 -0
  19. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/icon.py +0 -0
  20. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/pairing.py +0 -0
  21. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/pendify.ico +0 -0
  22. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/plate_almena.py +0 -0
  23. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/tray.py +0 -0
  24. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/update.py +0 -0
  25. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/watcher.py +0 -0
  26. {pendify-0.1.5 → pendify-0.1.7}/src/pendify/worker.py +0 -0
  27. {pendify-0.1.5 → pendify-0.1.7}/src/pendify.egg-info/SOURCES.txt +0 -0
  28. {pendify-0.1.5 → pendify-0.1.7}/src/pendify.egg-info/dependency_links.txt +0 -0
  29. {pendify-0.1.5 → pendify-0.1.7}/src/pendify.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pendify
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/ElkinDev/PendiFy
@@ -28,7 +28,7 @@ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en Po
28
28
 
29
29
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
30
30
 
31
- La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
31
+ La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine. Si PendiFy ya está abierto, la línea instala la versión nueva y la copia nueva reemplaza a la anterior por sí sola.
32
32
 
33
33
  La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
34
34
 
@@ -93,7 +93,7 @@ Press Windows + R, paste this whole line and press Enter. It also works in Power
93
93
 
94
94
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
95
95
 
96
- The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
96
+ The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done. If PendiFy is already open, the line installs the new version and the new copy replaces the older one by itself.
97
97
 
98
98
  The window stays open at the end so you can read the result; close it when it is done.
99
99
 
@@ -12,7 +12,7 @@ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en Po
12
12
 
13
13
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
14
14
 
15
- La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
15
+ La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine. Si PendiFy ya está abierto, la línea instala la versión nueva y la copia nueva reemplaza a la anterior por sí sola.
16
16
 
17
17
  La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
18
18
 
@@ -77,7 +77,7 @@ Press Windows + R, paste this whole line and press Enter. It also works in Power
77
77
 
78
78
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
79
79
 
80
- The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
80
+ The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done. If PendiFy is already open, the line installs the new version and the new copy replaces the older one by itself.
81
81
 
82
82
  The window stays open at the end so you can read the result; close it when it is done.
83
83
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pendify"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -10,7 +10,10 @@
10
10
  The page and the watcher stop together on Ctrl+C, on Ctrl+Break, on the page's quit button and on the quit of
11
11
  the icon by the clock, which remove the run file and exit 0. One instance per config folder: a second start
12
12
  opens the running page and exits 0, and one made while that program is still closing after its quit waits for
13
- it to end and starts. A restart the page asks for («Reiniciar ahora», once a newer version is installed, or the
13
+ it to end and starts. A start of a newer version over a running older copy replaces it: the older copy is asked to
14
+ stop through its page's /replace with the run file's secret, or ended by its pid when it has no secret or its page
15
+ does not answer, and this start then claims the run file as a first one does. A restart the page asks for
16
+ («Reiniciar ahora», once a newer version is installed, or the
14
17
  icon's «Reiniciar para actualizar») starts a new copy once the run file is released: without --quiet, so its page
15
18
  opens, and with the --dry, --data-dir, --worker and --client-lockfile of this one.
16
19
  Where no console is attached (a pythonw start), the line a start ends on is also shown in a
@@ -18,12 +21,15 @@ message box. `--data-dir`, `--worker` and `--client-lockfile` are the
18
21
  test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
19
22
  """
20
23
  import argparse
24
+ import http.client
21
25
  import os
22
26
  import signal
23
27
  import subprocess
24
28
  import sys
25
29
  import threading
26
30
  import time
31
+ import urllib.parse
32
+ import urllib.request
27
33
  import webbrowser
28
34
  from pathlib import Path
29
35
 
@@ -46,6 +52,16 @@ PAGE_NOT_KNOWN_LINE = ("already running: the page of the program that runs is no
46
52
  CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
47
53
  FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
48
54
  RESTART_FAILED_LINE = "cannot restart: the new copy did not start; start the program again"
55
+ # The line a start that replaced an older copy shows, in the page's language: the older copy's version, «desconocida»
56
+ # or "unknown" for a record with none, then this one's.
57
+ REPLACED_LINES = {"es": "Se reemplazó la copia anterior ({old}) por esta ({new}).",
58
+ "en": "The older copy ({old}) was replaced by this one ({new})."}
59
+ UNKNOWN_VERSION = {"es": "desconocida", "en": "unknown"}
60
+ # The line a start prints when the older copy it would replace cannot be ended, in the page's language, with the
61
+ # system's reason.
62
+ CLOSE_REFUSED_LINES = {"es": "No se pudo cerrar la copia anterior: {reason}",
63
+ "en": "The older copy could not be closed: {reason}"}
64
+ REPLACE_TIMEOUT_SECONDS = 5.0 # the replace post to the older copy's page
49
65
  _MB_ICONINFORMATION = 0x40
50
66
  _MB_SETFOREGROUND = 0x10000
51
67
  # The restart's new copy: no console, its own process group, out of this one's.
@@ -181,6 +197,65 @@ def _restart(args, spawn, show, opens_page):
181
197
  return 0
182
198
 
183
199
 
200
+ def _replaces(holder):
201
+ """True when this copy is newer than `holder`, a live holder that is not closing: X.Y.Z compared as tuples of
202
+ ints, and a holder with no version, as an older copy writes its record, older than any. A copy whose own version
203
+ is not X.Y.Z replaces nothing."""
204
+ if update._parts(update.RUNNING_VERSION) is None:
205
+ return False
206
+ return holder["version"] is None or update.newer(update.RUNNING_VERSION, holder["version"])
207
+
208
+
209
+ def _ask_replace(holder):
210
+ """True when the holder's page answered 202 to POST /replace with the run file's secret; False for any other
211
+ answer, none, or no port."""
212
+ if holder["port"] is None:
213
+ return False
214
+ request = urllib.request.Request(f"http://{page.ADDRESS}:{holder['port']}/replace", method="POST",
215
+ data=urllib.parse.urlencode({"secret": holder["secret"]}).encode())
216
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # a loopback address never goes through one
217
+ try:
218
+ with opener.open(request, timeout=REPLACE_TIMEOUT_SECONDS) as answer:
219
+ return answer.status == 202
220
+ except (OSError, http.client.HTTPException): # a refusal (urllib.error.HTTPError), no answer in time, a broken one
221
+ return False
222
+
223
+
224
+ def _kept_language(store):
225
+ """The page's language before the page is made, with no browser to ask: the kept choice, else Spanish, as the
226
+ icon's words. A config file that cannot be read gives Spanish: the choice only words one line."""
227
+ try:
228
+ return store.read_lang() or page.language(None)
229
+ except (OSError, ValueError):
230
+ return page.language(None)
231
+
232
+
233
+ def _replace(run, holder, terminate, store):
234
+ """The older `holder` dealt with, then the run file claimed again: (the claim's answer, `holder` when this start
235
+ replaced it, else None). It is asked through its page's /replace; with no secret in its record or no 202 from its
236
+ page, `terminate` is asked to end it by its pid, which it does only for the process its record names
237
+ (runfile.terminate):
238
+ - a 202, or ended: once the run file no longer names it as a live process within CLOSING_WAIT_SECONDS, its file
239
+ gone or left stale by its death, the file is claimed; a holder still alive then is handed back as it was read;
240
+ - not-ours: the pid names another process now, so the file is stale: evicted while it still holds that record,
241
+ then claimed as a first start claims it;
242
+ - refused: the reason printed on one line and the file waited for as long, since the pid may be dying under
243
+ another start's terminate, then claimed again: a holder that still stands is read afresh, never the snapshot."""
244
+ if holder["secret"] is None or not _ask_replace(holder):
245
+ answer = terminate(holder["pid"], holder["started"], run.path)
246
+ if answer == runfile.NOT_OURS:
247
+ run.evict(holder)
248
+ return run.claim(), holder
249
+ if answer == runfile.REFUSED:
250
+ reason = answer.error.strerror or answer.error
251
+ print(CLOSE_REFUSED_LINES[_kept_language(store)].format(reason=reason), flush=True)
252
+ run.wait_released(holder, CLOSING_WAIT_SECONDS)
253
+ return run.claim(), None
254
+ if run.wait_released(holder, CLOSING_WAIT_SECONDS):
255
+ return run.claim(), holder
256
+ return holder, None
257
+
258
+
184
259
  def _ready_version(updater):
185
260
  """The version an install made ready, for the icon's item; None in any other state."""
186
261
  seen = updater.snapshot()
@@ -188,14 +263,19 @@ def _ready_version(updater):
188
263
 
189
264
 
190
265
  def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray, updater,
191
- spawn):
266
+ spawn, terminate):
192
267
  # A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
193
268
  calm = (lambda line: None) if args.quiet else show
194
269
  run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
270
+ replaced = None # the record of the older copy this start replaced
195
271
  try:
196
272
  holder = run.claim()
197
273
  if holder is not None and holder["closing"] and run.wait_released(holder, CLOSING_WAIT_SECONDS):
198
274
  holder = run.claim() # the closing copy let its file go: this start claims it as a first one does
275
+ # A running older copy is replaced (_replace); a holder that still stands after it keeps the road below,
276
+ # its page opened.
277
+ if holder is not None and not holder["closing"] and _replaces(holder):
278
+ holder, replaced = _replace(run, holder, terminate, store)
199
279
  except runfile.FolderNotWritable as refused:
200
280
  return _ends(FOLDER_FAILED_LINE.format(path=refused.filename), 1, show)
201
281
  except OSError:
@@ -231,6 +311,7 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
231
311
 
232
312
  pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
233
313
  on_resume=watch.resume, autostart=autostart, events=watch.events)
314
+ pairing_page.run = run # its /replace answers the secret the publish below writes
234
315
  url = pairing_page.start()
235
316
  # The update's rounds on their own thread (update.py), built once the page serves; its restart is the
236
317
  # page's quit, after the flag read below once the run file is free; pip's output goes to the config folder.
@@ -249,6 +330,12 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
249
330
  if mode != update.OFF:
250
331
  updating.start()
251
332
  run.publish(pairing_page.port)
333
+ if replaced is not None: # in the page's language, as the icon's words; a box never holds the start
334
+ lang = pairing_page.language_of(None)
335
+ line = REPLACED_LINES[lang].format(old=replaced["version"] or UNKNOWN_VERSION[lang],
336
+ new=update.RUNNING_VERSION)
337
+ print(line, flush=True)
338
+ threading.Thread(target=calm, args=(line,), daemon=True).start()
252
339
  print(f"page: {url}", flush=True)
253
340
  if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
254
341
  print("open the address above in a browser", flush=True)
@@ -292,13 +379,14 @@ def _break_as_interrupt():
292
379
 
293
380
  def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
294
381
  beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
295
- sleep=time.sleep, tray=Tray, updater=update.Updater, spawn=subprocess.Popen):
382
+ sleep=time.sleep, tray=Tray, updater=update.Updater, spawn=subprocess.Popen, terminate=runfile.terminate):
296
383
  """`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
297
384
  (the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
298
385
  line reaches anybody; `clock` and `sleep` time the run file's re-reads. `tray` makes the icon by the clock, the
299
386
  seam a test run fills with a fake, so no test shows a real icon. `updater` makes the update and `spawn` starts
300
387
  the restart's new copy, the seams a test run fills with fakes, so no test reaches PyPI, runs pip or starts a
301
- copy."""
388
+ copy. `terminate` ends an older copy by its pid when it cannot be asked to stop, the seam every test run fakes, so
389
+ no test ends a process."""
302
390
  args = _arguments(sys.argv[1:] if argv is None else argv)
303
391
  store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
304
392
  base = getattr(args, "worker", worker.BASE_URL)
@@ -314,7 +402,7 @@ def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT
314
402
  try:
315
403
  return _serve(args, store, base, timeout, opener, stop or threading.Event(),
316
404
  delay or watcher.accept_delay, beep, show, clock, sleep,
317
- autostart if autostart is not None else Autostart(), tray, updater, spawn)
405
+ autostart if autostart is not None else Autostart(), tray, updater, spawn, terminate)
318
406
  finally:
319
407
  if previous is not None:
320
408
  signal.signal(signal.SIGBREAK, previous)
@@ -8,6 +8,7 @@ nothing from any origin; no request line and no body is ever logged.
8
8
  import hmac
9
9
  import html
10
10
  import json
11
+ import random
11
12
  import secrets
12
13
  import sys
13
14
  import threading
@@ -49,12 +50,11 @@ WORDS = {
49
50
  "intro": "Este programa envía sus avisos a tu cuenta de Pendi. Para eso, este PC tiene que quedar enlazado "
50
51
  "con tu cuenta.",
51
52
  "code_label": "Clave de este PC:",
52
- "qr_for": "Escanea este código con Pendi para recibir en el teléfono las notificaciones de este PC: cuando "
53
- "empieza la partida o cuando se acepta la cola.",
54
- "qr_mask": "Código oculto",
53
+ "qr_for": "ESCANEA ESTE CÓDIGO QR con tu teléfono para recibir notificaciones cuando empiece la partida y "
54
+ "se acepte la cola.",
55
+ "qr_press": "Pulsa Mostrar el código",
55
56
  "show_code": "Mostrar el código",
56
57
  "hide_code": "Ocultar el código",
57
- "mask": "Clave oculta",
58
58
  "fold": "Más opciones: escribir los valores del enlace u olvidar este PC",
59
59
  "credit": "Creado por",
60
60
  "sponsor_by": "Patrocinado por Pendiapp.com",
@@ -141,12 +141,11 @@ WORDS = {
141
141
  "intro": "This program sends its alerts to your Pendi account. For that, this PC has to be linked to your "
142
142
  "account.",
143
143
  "code_label": "This PC's key:",
144
- "qr_for": "Scan this code with Pendi to get this PC's notifications on your phone: when the match starts or "
145
- "the queue is accepted.",
146
- "qr_mask": "Hidden code",
144
+ "qr_for": "SCAN THIS QR CODE with your phone to get notifications when the match starts and the queue is "
145
+ "accepted.",
146
+ "qr_press": "Press Show the code",
147
147
  "show_code": "Show the code",
148
148
  "hide_code": "Hide the code",
149
- "mask": "Key hidden",
150
149
  "fold": "More options: type the link values, or forget this PC",
151
150
  "credit": "Created by",
152
151
  "sponsor_by": "Sponsored by Pendiapp.com",
@@ -332,6 +331,9 @@ _STYLE = (":root{color-scheme:light dark;" + _LIGHT + "}\n"
332
331
  "button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
333
332
  "form[action='/check'] button,form[action='/relink'] button{width:100%;background:var(--brand);"
334
333
  "color:var(--on-brand)}\n"
334
+ # The form under the QR and the linked page's relink form keep the card «Actividad»'s rhythm (OR-106): from
335
+ # 880 px the key's card leaves for its column, and form{margin:0} alone left each flush under «Este PC».
336
+ "form[action='/check'],form[action='/relink']{margin-top:16px}\n"
335
337
  "form[action='/forget'] button{background:transparent;border-color:var(--line);color:var(--danger)}\n"
336
338
  "form[action='/quit'] button{padding-inline:16px;background:transparent;color:var(--ink2)}\n"
337
339
  ".more{display:grid;gap:16px;margin-top:32px}\n"
@@ -423,7 +425,21 @@ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
423
425
  '.key[data-shown="true"] .mask{visibility:hidden}\n'
424
426
  '.key-card:has(.key[data-shown="false"]) .modules{visibility:hidden}\n'
425
427
  '.key-card:has(.key[data-shown="true"]) .qr-mask{visibility:hidden}\n'
428
+ # A press on either mask shows the code (OR-108).
429
+ ".qr-mask,.mask{cursor:pointer}\n"
426
430
  ".qr-for{margin:0}\n"
431
+ # The label with impact (OR-105, the pfmask mockup): the #state linked block's recipe, stretched to the
432
+ # card, the owner's leading capitals as its lead line and the rest under it. The masks over the QR (OR-104,
433
+ # qr.MASKS): the board's two tokens, and the instruction as plain text in the outline token over a halo in
434
+ # the masks' own ground, qr.GROUND, with no plate.
435
+ ".key-card .qr-for{justify-self:stretch;padding:16px 20px;border-radius:12px;background:var(--tonal);"
436
+ "color:var(--on-tonal);font-size:16px;line-height:24px;font-weight:500}\n"
437
+ ".key-card .qr-for b{display:block;margin:0 0 2px;font-size:20px;line-height:28px;font-weight:600;"
438
+ "letter-spacing:.02em}\n"
439
+ ".qm-1{fill:var(--brand)}.qm-2{fill:var(--tonal)}\n"
440
+ f".qm-say{{fill:var(--px-line);stroke:{qr.GROUND};stroke-width:10px;stroke-linejoin:round;"
441
+ "paint-order:stroke;font:600 28px system-ui,sans-serif;text-anchor:middle;dominant-baseline:central}"
442
+ ".qm-board{font-size:32px}\n"
427
443
  ".mask{display:flex;align-items:flex-end;gap:10px}\n"
428
444
  ".mask .grp{display:flex;align-items:flex-end;gap:2px}\n"
429
445
  ".mask svg{display:block;flex:none}\n"
@@ -560,6 +576,19 @@ _SCRIPT = ("const s=document.getElementById('state');const w=document.getElement
560
576
  "function showKey(on){k.dataset.shown=String(on);r.textContent=on?r.dataset.hide:r.dataset.show;"
561
577
  "clearTimeout(hide);if(on)hide=setTimeout(()=>showKey(false),60000);}"
562
578
  "if(r)r.addEventListener('click',()=>showKey(k.dataset.shown!=='true'));"
579
+ # A key held after a mask press repeats on the reveal the focus moved to; its repeats would set it
580
+ # active or click it and hide the code again, so only a repeat Enter or Space loses its default there.
581
+ "if(r)r.addEventListener('keydown',e=>{if(e.repeat&&(e.key==='Enter'||e.key===' '))e.preventDefault();});"
582
+ # Both masks do what the reveal does (OR-108), and never hide the code. The QR's mask is an image in
583
+ # the svg that a click shows the code from; the keyboard and the screen reader have the reveal. The
584
+ # key's mask is a button: a click, Enter or Space shows the code, then focus moves to the reveal,
585
+ # since the mask turns visibility:hidden and would drop it to the body. Enter and Space both prevent
586
+ # their default: Space would scroll the page, and Enter's keypress would go to the reveal the focus
587
+ # moved to, which would toggle the code back to hidden.
588
+ "document.querySelectorAll('.qr-mask').forEach(m=>m.addEventListener('click',()=>showKey(true)));"
589
+ "document.querySelectorAll('.mask').forEach(m=>{const press=()=>{showKey(true);r.focus();};"
590
+ "m.addEventListener('click',press);m.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){"
591
+ "e.preventDefault();press();}});});"
563
592
  # The theme button (pendiapp.com's assets/theme.js): the choice goes to data-theme and to localStorage,
564
593
  # and it is posted to /theme, which keeps it in config.json: the page's port changes on every run, so
565
594
  # its localStorage is a new origin each time. The server's value, served as data-theme, wins on the next
@@ -670,9 +699,24 @@ _NOSCRIPT_STYLE = ('.key[data-shown="false"] .code,.key-card:has(.key[data-shown
670
699
  '{visibility:visible}.mask,.qr-mask,.reveal{display:none}')
671
700
 
672
701
 
702
+ def _lead(sentence):
703
+ """The QR label's leading capitals and the rest (OR-105): the words before the first one that holds a lower-case
704
+ letter, and the words from it. The tables keep the owner's sentence as one string; the page draws the capitals
705
+ as the label's lead line."""
706
+ words = sentence.split(" ")
707
+ for i, word in enumerate(words):
708
+ if any(letter.islower() for letter in word):
709
+ if i == 0:
710
+ break
711
+ return " ".join(words[:i]), " ".join(words[i:])
712
+ raise ValueError(f"the label holds no leading capitals followed by lower-case words: {sentence!r}")
713
+
714
+
673
715
  def _mask(words):
674
- """The mask over the key, three groups of the four heads, named for a screen reader. `words` are escaped."""
675
- return f'<span class="mask" role="img" aria-label="{words["mask"]}">{_MASK_GROUP * 3}</span>'
716
+ """The mask over the key, three groups of the four heads, a control named by the press words, as the QR's
717
+ mask is: a press on either shows the code (lane pfpress). `words` are escaped."""
718
+ return (f'<span class="mask" role="button" tabindex="0" aria-label="{words["qr_press"]}">'
719
+ f'{_MASK_GROUP * 3}</span>')
676
720
 
677
721
 
678
722
  def _sponsor_runs():
@@ -765,7 +809,7 @@ def _switch(words, token, lang):
765
809
 
766
810
  class PairingPage:
767
811
  def __init__(self, state, watch=None, on_quit=None, on_pause=None, on_resume=None, autostart=None, events=None,
768
- updater=None):
812
+ updater=None, mask=None):
769
813
  """`watch` answers the watcher's snapshot; without it the page shows no watcher line. `on_quit` stops
770
814
  the program; without it the page shows no quit button and /quit is no route. `on_pause` and `on_resume`
771
815
  pause and resume the watcher; without them /pause and /resume are no routes. `autostart` is the start
@@ -775,7 +819,10 @@ class PairingPage:
775
819
  events, oldest first; with it /state carries the log's lines and the page draws them in the card «Actividad»,
776
820
  without it no log key and no card. `updater` is the update (update.py), set by main once the page serves;
777
821
  with it /state carries its state, the page draws its line under the state line, and /update and /restart
778
- are routes; without it none of them."""
822
+ are routes; without it none of them. `mask` is the QR's mask, one of qr.MASKS; without it the page picks one
823
+ here, once, at random (the standard random module: a cosmetic pick), so each start, a fresh install's first
824
+ included, shows the bust or the board, and every render of this page draws the same one (owner 2026-10-02
825
+ 12:4x)."""
779
826
  self.state = state
780
827
  self.watch = watch
781
828
  self.on_quit = on_quit
@@ -783,6 +830,8 @@ class PairingPage:
783
830
  self.autostart = autostart if autostart is not None and autostart.available else None
784
831
  self.events = events
785
832
  self.updater = updater
833
+ self.mask = mask if mask is not None else random.choice(qr.MASKS)
834
+ self.run = None # the RunFile main hands the page: /replace answers its secret once published
786
835
  self.token = secrets.token_urlsafe(32)
787
836
  self.server = None
788
837
  self.port = None
@@ -937,16 +986,18 @@ class PairingPage:
937
986
  link.append(log_card)
938
987
  if secret is not None:
939
988
  modules = qr.encode(qr.pairing_address(secret).encode("ascii")).modules
940
- drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="qr-for", mask=words["qr_mask"])
989
+ drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="qr-for", mask=self.mask,
990
+ mask_words=words["qr_press"])
941
991
  if drawn is None: # the scene is drawn for version 3 only
942
- drawn = qr.svg(modules, labelledby="qr-for", mask=words["qr_mask"])
992
+ drawn = qr.svg(modules, labelledby="qr-for", mask=self.mask, mask_words=words["qr_press"])
993
+ lead, rest = _lead(words["qr_for"])
943
994
  link += [# The code is hidden until asked (owner 2026-10-02): the QR's modules under their pixelated
944
995
  # mask and the key's text under its heads until its person presses the reveal, a real button
945
996
  # whose label says what a press does, with the label of what the QR is for over it, the QR's
946
997
  # one sentence and its name; _SCRIPT hides both again 60 s after a show. The drawings stay.
947
998
  f'<figure class="key-card">{drawn}<p class="key" data-shown="false"><span class="key-label">'
948
999
  f'{words["code_label"]}</span> <span class="key-val">{_mask(words)}<span class="code" id="code">'
949
- f'{codes.display(secret)}</span></span></p><p class="qr-for" id="qr-for">{words["qr_for"]}</p>'
1000
+ f'{codes.display(secret)}</span></span></p><p class="qr-for" id="qr-for"><b>{lead}</b> {rest}</p>'
950
1001
  f'<button type="button" class="reveal" '
951
1002
  f'aria-controls="code" data-show="{words["show_code"]}" data-hide="{words["hide_code"]}">'
952
1003
  f'{words["show_code"]}</button>{_PARTY}</figure>',
@@ -1188,6 +1239,8 @@ def _handler(page):
1188
1239
  routes += ("/resume",) if page.on_resume is not None else ()
1189
1240
  routes += ("/autostart",) if page.autostart is not None else ()
1190
1241
  routes += ("/update", "/restart") if page.updater is not None else ()
1242
+ replace_secret = page.run.secret if page.run is not None else None
1243
+ routes += ("/replace",) if page.on_quit is not None and replace_secret is not None else ()
1191
1244
  if path not in routes:
1192
1245
  return self._refuse(404)
1193
1246
  if length is None: # chunked or absent is 411, anything but plain digits 400
@@ -1200,6 +1253,17 @@ def _handler(page):
1200
1253
  except ValueError: # more fields than any form of the page has
1201
1254
  return self._refuse(400)
1202
1255
  form = {key: values[0] for key, values in fields.items()}
1256
+ if path == "/replace":
1257
+ # A newer copy started over this one (__main__.py): it has no page token, so the run file's secret is
1258
+ # its check; then the stop of «Salir», answered first as /quit is.
1259
+ if not hmac.compare_digest(form.get("secret", "").encode(), replace_secret.encode()):
1260
+ return self._refuse(403)
1261
+ try:
1262
+ closing = "Cerrando" if page.language_of(self.headers.get("Accept-Language")) == "es" else "Closing"
1263
+ self._send(202, "text/plain; charset=utf-8", closing)
1264
+ finally:
1265
+ page.on_quit()
1266
+ return
1203
1267
  if not hmac.compare_digest(form.get("token", "").encode(), page.token.encode()):
1204
1268
  return self._refuse(403)
1205
1269
  if path == "/quit":