pendify 0.1.5__tar.gz → 0.1.6__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.6}/PKG-INFO +3 -3
  2. {pendify-0.1.5 → pendify-0.1.6}/README.md +2 -2
  3. {pendify-0.1.5 → pendify-0.1.6}/pyproject.toml +1 -1
  4. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/__main__.py +93 -5
  5. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/page.py +60 -11
  6. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/qr.py +129 -37
  7. pendify-0.1.6/src/pendify/runfile.py +501 -0
  8. {pendify-0.1.5 → pendify-0.1.6/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.6}/LICENSE +0 -0
  11. {pendify-0.1.5 → pendify-0.1.6}/MANIFEST.in +0 -0
  12. {pendify-0.1.5 → pendify-0.1.6}/setup.cfg +0 -0
  13. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/__init__.py +0 -0
  14. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/alert.py +0 -0
  15. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/autostart.py +0 -0
  16. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/client.py +0 -0
  17. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/codes.py +0 -0
  18. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/config.py +0 -0
  19. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/icon.py +0 -0
  20. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/pairing.py +0 -0
  21. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/pendify.ico +0 -0
  22. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/plate_almena.py +0 -0
  23. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/tray.py +0 -0
  24. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/update.py +0 -0
  25. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/watcher.py +0 -0
  26. {pendify-0.1.5 → pendify-0.1.6}/src/pendify/worker.py +0 -0
  27. {pendify-0.1.5 → pendify-0.1.6}/src/pendify.egg-info/SOURCES.txt +0 -0
  28. {pendify-0.1.5 → pendify-0.1.6}/src/pendify.egg-info/dependency_links.txt +0 -0
  29. {pendify-0.1.5 → pendify-0.1.6}/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.6
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.6"
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,9 +50,9 @@ 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
58
  "mask": "Clave oculta",
@@ -141,9 +142,9 @@ WORDS = {
141
142
  "intro": "This program sends its alerts to your Pendi account. For that, this PC has to be linked to your "
142
143
  "account.",
143
144
  "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",
145
+ "qr_for": "SCAN THIS QR CODE with your phone to get notifications when the match starts and the queue is "
146
+ "accepted.",
147
+ "qr_press": "Press Show the code",
147
148
  "show_code": "Show the code",
148
149
  "hide_code": "Hide the code",
149
150
  "mask": "Key hidden",
@@ -332,6 +333,9 @@ _STYLE = (":root{color-scheme:light dark;" + _LIGHT + "}\n"
332
333
  "button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
333
334
  "form[action='/check'] button,form[action='/relink'] button{width:100%;background:var(--brand);"
334
335
  "color:var(--on-brand)}\n"
336
+ # The form under the QR and the linked page's relink form keep the card «Actividad»'s rhythm (OR-106): from
337
+ # 880 px the key's card leaves for its column, and form{margin:0} alone left each flush under «Este PC».
338
+ "form[action='/check'],form[action='/relink']{margin-top:16px}\n"
335
339
  "form[action='/forget'] button{background:transparent;border-color:var(--line);color:var(--danger)}\n"
336
340
  "form[action='/quit'] button{padding-inline:16px;background:transparent;color:var(--ink2)}\n"
337
341
  ".more{display:grid;gap:16px;margin-top:32px}\n"
@@ -424,6 +428,18 @@ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
424
428
  '.key-card:has(.key[data-shown="false"]) .modules{visibility:hidden}\n'
425
429
  '.key-card:has(.key[data-shown="true"]) .qr-mask{visibility:hidden}\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"
@@ -670,6 +686,19 @@ _NOSCRIPT_STYLE = ('.key[data-shown="false"] .code,.key-card:has(.key[data-shown
670
686
  '{visibility:visible}.mask,.qr-mask,.reveal{display:none}')
671
687
 
672
688
 
689
+ def _lead(sentence):
690
+ """The QR label's leading capitals and the rest (OR-105): the words before the first one that holds a lower-case
691
+ letter, and the words from it. The tables keep the owner's sentence as one string; the page draws the capitals
692
+ as the label's lead line."""
693
+ words = sentence.split(" ")
694
+ for i, word in enumerate(words):
695
+ if any(letter.islower() for letter in word):
696
+ if i == 0:
697
+ break
698
+ return " ".join(words[:i]), " ".join(words[i:])
699
+ raise ValueError(f"the label holds no leading capitals followed by lower-case words: {sentence!r}")
700
+
701
+
673
702
  def _mask(words):
674
703
  """The mask over the key, three groups of the four heads, named for a screen reader. `words` are escaped."""
675
704
  return f'<span class="mask" role="img" aria-label="{words["mask"]}">{_MASK_GROUP * 3}</span>'
@@ -765,7 +794,7 @@ def _switch(words, token, lang):
765
794
 
766
795
  class PairingPage:
767
796
  def __init__(self, state, watch=None, on_quit=None, on_pause=None, on_resume=None, autostart=None, events=None,
768
- updater=None):
797
+ updater=None, mask=None):
769
798
  """`watch` answers the watcher's snapshot; without it the page shows no watcher line. `on_quit` stops
770
799
  the program; without it the page shows no quit button and /quit is no route. `on_pause` and `on_resume`
771
800
  pause and resume the watcher; without them /pause and /resume are no routes. `autostart` is the start
@@ -775,7 +804,10 @@ class PairingPage:
775
804
  events, oldest first; with it /state carries the log's lines and the page draws them in the card «Actividad»,
776
805
  without it no log key and no card. `updater` is the update (update.py), set by main once the page serves;
777
806
  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."""
807
+ are routes; without it none of them. `mask` is the QR's mask, one of qr.MASKS; without it the page picks one
808
+ here, once, at random (the standard random module: a cosmetic pick), so each start, a fresh install's first
809
+ included, shows the bust or the board, and every render of this page draws the same one (owner 2026-10-02
810
+ 12:4x)."""
779
811
  self.state = state
780
812
  self.watch = watch
781
813
  self.on_quit = on_quit
@@ -783,6 +815,8 @@ class PairingPage:
783
815
  self.autostart = autostart if autostart is not None and autostart.available else None
784
816
  self.events = events
785
817
  self.updater = updater
818
+ self.mask = mask if mask is not None else random.choice(qr.MASKS)
819
+ self.run = None # the RunFile main hands the page: /replace answers its secret once published
786
820
  self.token = secrets.token_urlsafe(32)
787
821
  self.server = None
788
822
  self.port = None
@@ -937,16 +971,18 @@ class PairingPage:
937
971
  link.append(log_card)
938
972
  if secret is not None:
939
973
  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"])
974
+ drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="qr-for", mask=self.mask,
975
+ mask_words=words["qr_press"])
941
976
  if drawn is None: # the scene is drawn for version 3 only
942
- drawn = qr.svg(modules, labelledby="qr-for", mask=words["qr_mask"])
977
+ drawn = qr.svg(modules, labelledby="qr-for", mask=self.mask, mask_words=words["qr_press"])
978
+ lead, rest = _lead(words["qr_for"])
943
979
  link += [# The code is hidden until asked (owner 2026-10-02): the QR's modules under their pixelated
944
980
  # mask and the key's text under its heads until its person presses the reveal, a real button
945
981
  # whose label says what a press does, with the label of what the QR is for over it, the QR's
946
982
  # one sentence and its name; _SCRIPT hides both again 60 s after a show. The drawings stay.
947
983
  f'<figure class="key-card">{drawn}<p class="key" data-shown="false"><span class="key-label">'
948
984
  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>'
985
+ f'{codes.display(secret)}</span></span></p><p class="qr-for" id="qr-for"><b>{lead}</b> {rest}</p>'
950
986
  f'<button type="button" class="reveal" '
951
987
  f'aria-controls="code" data-show="{words["show_code"]}" data-hide="{words["hide_code"]}">'
952
988
  f'{words["show_code"]}</button>{_PARTY}</figure>',
@@ -1188,6 +1224,8 @@ def _handler(page):
1188
1224
  routes += ("/resume",) if page.on_resume is not None else ()
1189
1225
  routes += ("/autostart",) if page.autostart is not None else ()
1190
1226
  routes += ("/update", "/restart") if page.updater is not None else ()
1227
+ replace_secret = page.run.secret if page.run is not None else None
1228
+ routes += ("/replace",) if page.on_quit is not None and replace_secret is not None else ()
1191
1229
  if path not in routes:
1192
1230
  return self._refuse(404)
1193
1231
  if length is None: # chunked or absent is 411, anything but plain digits 400
@@ -1200,6 +1238,17 @@ def _handler(page):
1200
1238
  except ValueError: # more fields than any form of the page has
1201
1239
  return self._refuse(400)
1202
1240
  form = {key: values[0] for key, values in fields.items()}
1241
+ if path == "/replace":
1242
+ # A newer copy started over this one (__main__.py): it has no page token, so the run file's secret is
1243
+ # its check; then the stop of «Salir», answered first as /quit is.
1244
+ if not hmac.compare_digest(form.get("secret", "").encode(), replace_secret.encode()):
1245
+ return self._refuse(403)
1246
+ try:
1247
+ closing = "Cerrando" if page.language_of(self.headers.get("Accept-Language")) == "es" else "Closing"
1248
+ self._send(202, "text/plain; charset=utf-8", closing)
1249
+ finally:
1250
+ page.on_quit()
1251
+ return
1203
1252
  if not hmac.compare_digest(form.get("token", "").encode(), page.token.encode()):
1204
1253
  return self._refuse(403)
1205
1254
  if path == "/quit":
@@ -223,22 +223,82 @@ def penalty(modules):
223
223
  + 10 * (abs(dark * 2 - total) * 10 // total))
224
224
 
225
225
 
226
- # The mask over the pairing QR's modules (owner 2026-10-02 10:3x): one pixelated figure, the same for every key and
227
- # drawn from no module of any, a 7 by 7 mosaic laid over the symbol. F is a finder's corner (svg() draws the finder
228
- # there as the real one is; the scene's plate holds its own), # a dark block, + a mid block, . a light block, each in
229
- # the drawing's own colours.
230
- _MASK_ART = ("FF.#+FF",
231
- "FF#.#FF",
232
- "+#..##.",
233
- "#.#+..#",
234
- ".#.#+#.",
235
- "FF+..#.",
236
- "FF.#+.#")
226
+ # The two masks over the pairing QR's modules (owner 2026-10-02 12:4x, ledger OR-104, lane pfmaskimpl), one picked at
227
+ # random by the page once per start: the bust, a helmeted bust in left profile of 32 by 32 blocks (the pfmask design
228
+ # lane's bust.py, options A and C of mockups/pendify-qr-mask-2026-10-02.html), and the board, 8 by 8 flat cells in
229
+ # the page's --brand and --tonal (option B). Each lies on one GROUND path over the whole symbol, finders and timing
230
+ # included, so no finder, module or grid shows through, and each carries the instruction as plain text (the owner's
231
+ # correction: both with option C's words). Neither is drawn from any module of any key.
232
+ MASKS = ("bust", "board")
233
+ # The bust's blocks as bust.py prints them: '.' empty, 'o' the outline in the theme's --px-line (class "pl", as the
234
+ # party's sprites), the rest the party's colours, page._FIGURE_COLOURS: v d l the helmet, y a the plume, h s the plates.
235
+ _BUST = ("................................",
236
+ "................................",
237
+ "................................",
238
+ "..............ooooo.............",
239
+ ".............oyyyyyoy...........",
240
+ "............oyaaaaayyy..........",
241
+ "............oaayyyyaayy.........",
242
+ "............ooyyyyyyaayy........",
243
+ ".........ooodvoooyyyyaao........",
244
+ ".......oodvvvlvo.ooyyyaao.......",
245
+ "......odvvvvddlvo..ooyyayo......",
246
+ ".....oddvddddddlvo...ooaoo......",
247
+ ".....ooooodddddvvo.....o........",
248
+ ".....odddddddddvvo..............",
249
+ ".....ooooooodddddvo.............",
250
+ ".....odvvvvvoooddvvo............",
251
+ "....odddddddvo.ooddvooooooo.....",
252
+ "....oddddddddvo..oddvvoshhhoo...",
253
+ "....odddddddldvo.ooooossssshho..",
254
+ ".....oddddddddvooshhhhsssssss...",
255
+ "......oddddddddossssssssssss....",
256
+ ".....oooodooooossssoooooooo.....",
257
+ "......hhhoshhhhsssosssssss......",
258
+ ".......sshsssssssssssssss.......",
259
+ "........ssssssssssssssss........",
260
+ ".........ssssssssssssss.........",
261
+ "..........oooooooooooo..........",
262
+ "...........ssssssssss...........",
263
+ "............ssssssss............",
264
+ ".............ssssss.............",
265
+ "..............ssss..............",
266
+ "...............ss...............")
267
+ _BUST_COLOURS = {"v": "#6D28D9", "d": "#40277C", "l": "#C3B1F7", "y": "#FBBE3C", "a": "#A5510B", "h": "#ECE8F6",
268
+ "s": "#A79FC2"}
269
+ _BUST_CELL = 10.5 # one block in plate units: 5.25 px in the page's 288 px box, 8.75 px at 480
270
+ _BUST_SAY_ROW = 23.6 # the bust's row the instruction sits on: its lower band, the chest's upper plate
271
+ _BOARD = 8 # the board's cells a side
272
+
273
+
274
+ def _bust_paths():
275
+ """The bust as the page's _paths() draws a pixel drawing: one path per colour, one closed run per row."""
276
+ runs = {}
277
+ for y, row in enumerate(_BUST):
278
+ x = 0
279
+ while x < len(row):
280
+ if row[x] == ".":
281
+ x += 1
282
+ continue
283
+ start = x
284
+ while x < len(row) and row[x] == row[start]:
285
+ x += 1
286
+ runs.setdefault(row[start], []).append(f"M{start} {y}h{x - start}v1h-{x - start}z")
287
+ return "".join(f'<path class="pl" d="{"".join(d)}"/>' if key == "o" else
288
+ f'<path fill="{_BUST_COLOURS[key]}" d="{"".join(d)}"/>' for key, d in runs.items())
289
+
290
+
291
+ def _mask_named(mask, words):
292
+ """A mask is one of MASKS and is drawn with its words; anything else is refused, never drawn as another."""
293
+ if mask not in MASKS:
294
+ raise ValueError(f"no such mask: {mask!r}; the masks are {MASKS}")
295
+ if words is None:
296
+ raise ValueError(f"the mask {mask!r} is drawn with its words")
237
297
 
238
298
 
239
- def _mask_blocks():
240
- """The mosaic's blocks as (column, row, tone), the finders' corners left out."""
241
- return [(c, r, tone) for r, line in enumerate(_MASK_ART) for c, tone in enumerate(line) if tone != "F"]
299
+ def _say(words, x, y, board):
300
+ """The instruction on a mask: plain svg text in the page's .qm-say rule (no plate, no box, no corner)."""
301
+ return f'<text class="qm-say{" qm-board" if board else ""}" x="{x:.1f}" y="{y:.1f}">{words}</text>'
242
302
 
243
303
 
244
304
  def _finder(x, y):
@@ -248,17 +308,31 @@ def _finder(x, y):
248
308
  f'<rect x="{x + 2}" y="{y + 2}" width="3" height="3" rx="1" fill="{_QR_INK}"/>')
249
309
 
250
310
 
251
- def _flat_mask(n, q, name):
252
- """svg()'s mask: the three finders and the mosaic's blocks over the n by n symbol, named for a screen reader."""
253
- step = n / len(_MASK_ART)
254
- fill = {"#": _QR_INK, "+": _QR_RING, ".": _QR_TILE}
255
- blocks = "".join(f'<rect x="{q + c * step:.2f}" y="{q + r * step:.2f}" width="{step:.2f}" height="{step:.2f}" '
256
- f'fill="{fill[tone]}"/>' for c, r, tone in _mask_blocks())
257
- finders = "".join(_finder(left + q, top + q) for top, left in ((0, 0), (0, n - 7), (n - 7, 0)))
258
- return f'<g class="qr-mask" role="img" aria-label="{name}" shape-rendering="crispEdges">{finders}{blocks}</g>'
259
-
260
-
261
- def svg(modules, labelledby=None, mask=None):
311
+ def _flat_mask(n, q, mask, words):
312
+ """svg()'s mask, the scene's laid on the flat symbol: one GROUND path over the n by n symbol, finders included,
313
+ then the bust's 32 blocks fitted to it or the board's 8 by 8 cells, and the words placed as the scene places
314
+ them, in the scene's own units scaled to this box, so the page's one .qm-say rule draws both."""
315
+ side = n + 2 * q
316
+ ground = f'<path fill="{GROUND}" d="M{q} {q}h{n}v{n}h-{n}z"/>'
317
+ if mask == "bust":
318
+ art = (f'<g transform="translate({q} {q}) scale({n / len(_BUST):.6g})" shape-rendering="crispEdges">'
319
+ f"{_bust_paths()}</g>")
320
+ row = _BUST_SAY_ROW * n / len(_BUST)
321
+ else:
322
+ step = n / _BOARD
323
+ cells = {1: "", 2: ""}
324
+ for r in range(_BOARD):
325
+ for c in range(_BOARD):
326
+ cells[1 + (r + c) % 2] += f"M{q + c * step:.6g} {q + r * step:.6g}h{step:.6g}v{step:.6g}h-{step:.6g}z"
327
+ art = f'<path class="qm-1" d="{cells[1]}"/><path class="qm-2" d="{cells[2]}"/>'
328
+ row = n / 2
329
+ unit = VIEW / side
330
+ say = _say(words, (q + n / 2) * unit, (q + row) * unit, mask == "board")
331
+ return (f'<g class="qr-mask" role="img" aria-label="{words}">{ground}{art}'
332
+ f'<g transform="scale({side / VIEW:.6g})">{say}</g></g>')
333
+
334
+
335
+ def svg(modules, labelledby=None, mask=None, mask_words=None):
262
336
  """An inline SVG of the symbol on a white tile that keeps a four-module quiet zone, one unit per module.
263
337
 
264
338
  The grid is the encoder's; only how a module is painted changes. A dark data module is a 0.88 dot with
@@ -268,9 +342,12 @@ def svg(modules, labelledby=None, mask=None):
268
342
  corner centre inside the arc only while its radius is under 0.5*sqrt(2)/(sqrt(2)-1), about 1.707.
269
343
 
270
344
  Every module, finders and alignment patterns included, is drawn in one group, class "modules", over the tile.
271
- Given `mask`, the escaped name of the mask for a screen reader, a second group, class "qr-mask", draws the
272
- constant pixelated figure in the same place and size (_flat_mask); the page's rules show one of the two.
345
+ Given `mask`, one of MASKS, and `mask_words`, the escaped instruction drawn on it and its name for a screen
346
+ reader, a second group, class "qr-mask", draws that mask over the symbol (_flat_mask); the page's rules show one
347
+ of the two.
273
348
  """
349
+ if mask is not None:
350
+ _mask_named(mask, mask_words)
274
351
  n, q = len(modules), QUIET_ZONE
275
352
  side = n + 2 * q
276
353
  finders = ((0, 0), (0, n - 7), (n - 7, 0))
@@ -300,7 +377,7 @@ def svg(modules, labelledby=None, mask=None):
300
377
  f'<rect x="{x + 2}" y="{y + 2}" width="1" height="1" rx=".3" fill="{_QR_INK}"/>')
301
378
  out.append("</g>")
302
379
  if mask is not None:
303
- out.append(_flat_mask(n, q, mask))
380
+ out.append(_flat_mask(n, q, mask, mask_words))
304
381
  out.append("</svg>")
305
382
  return "".join(out)
306
383
 
@@ -401,7 +478,7 @@ def _runs(cells):
401
478
  return out
402
479
 
403
480
 
404
- def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
481
+ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None, mask_words=None):
405
482
  """The pairing QR as the scene: the plate image under a group of polygons. None when the symbol is not version 3,
406
483
  and the caller draws svg() instead.
407
484
 
@@ -413,8 +490,11 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
413
490
  near kerb clips it all.
414
491
 
415
492
  All that a key changes is one group, class "modules"; the plate is drawn before it and outside it. Given `mask`,
416
- the escaped name of the mask for a screen reader, a second group, class "qr-mask", lays the constant pixelated
417
- figure flat on the symbol under the same kerb: its blocks only, since the plate holds the finders."""
493
+ one of MASKS, and `mask_words`, the escaped instruction and the mask's name for a screen reader, a second group,
494
+ class "qr-mask", lays that mask flat on the symbol under the same kerb, over one GROUND path that covers the whole
495
+ symbol, the plate's finders and timing included, with the words over it."""
496
+ if mask is not None:
497
+ _mask_named(mask, mask_words)
418
498
  n = len(modules)
419
499
  if n != MODULES:
420
500
  return None
@@ -460,12 +540,24 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
460
540
 
461
541
  veil = ""
462
542
  if mask is not None:
463
- step = n / len(_MASK_ART)
464
- fill = {"#": INK, "+": FACE_U, ".": GROUND}
465
- blocks = {tone: "".join(area(c * step, r * step, (c + 1) * step, (r + 1) * step, 0.0)
466
- for c, r, t in _mask_blocks() if t == tone) for tone in fill}
467
- veil = (f'<g class="qr-mask" role="img" aria-label="{mask}" clip-path="url(#{uid}k)">'
468
- + "".join(f'<path fill="{fill[tone]}" d="{blocks[tone]}"/>' for tone in fill if blocks[tone]) + "</g>")
543
+ cx, cy = _pt(s0 + n / 2, s0 + n / 2, 0.0) # the symbol's centre
544
+ if mask == "bust":
545
+ gx, gy = cx - len(_BUST) / 2 * _BUST_CELL, cy - len(_BUST) / 2 * _BUST_CELL
546
+ art = (f'<g transform="translate({gx:.2f} {gy:.2f}) scale({_BUST_CELL})" shape-rendering="crispEdges">'
547
+ f"{_bust_paths()}</g>")
548
+ say = _say(mask_words, cx, gy + _BUST_SAY_ROW * _BUST_CELL, False)
549
+ else:
550
+ step = n / _BOARD
551
+ cells = {1: "", 2: ""}
552
+ for r in range(_BOARD):
553
+ for c in range(_BOARD):
554
+ cells[1 + (r + c) % 2] += area(c * step, r * step, (c + 1) * step, (r + 1) * step, 0.0)
555
+ art = f'<path class="qm-1" d="{cells[1]}"/><path class="qm-2" d="{cells[2]}"/>'
556
+ say = _say(mask_words, cx, cy, True)
557
+ # the drawing lies on the field under the kerb's clip, the ground path first; the words are laid over the
558
+ # scene, never clipped
559
+ veil = (f'<g class="qr-mask" role="img" aria-label="{mask_words}"><g clip-path="url(#{uid}k)">'
560
+ f'<path fill="{GROUND}" d="{area(0, 0, n, n, 0.0)}"/>{art}</g>{say}</g>')
469
561
  dot = _poly([((du - dv) * AX, (du + dv) * AY) for du, dv in _OCTAGON]) # one dot, centred on 0 0
470
562
  dots = {True: [], False: []}
471
563
  for r in range(n):