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.
- {pendify-0.1.5/src/pendify.egg-info → pendify-0.1.7}/PKG-INFO +3 -3
- {pendify-0.1.5 → pendify-0.1.7}/README.md +2 -2
- {pendify-0.1.5 → pendify-0.1.7}/pyproject.toml +1 -1
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/__main__.py +93 -5
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/page.py +79 -15
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/qr.py +129 -37
- pendify-0.1.7/src/pendify/runfile.py +501 -0
- {pendify-0.1.5 → pendify-0.1.7/src/pendify.egg-info}/PKG-INFO +3 -3
- pendify-0.1.5/src/pendify/runfile.py +0 -246
- {pendify-0.1.5 → pendify-0.1.7}/LICENSE +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/MANIFEST.in +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/setup.cfg +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/__init__.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/alert.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/autostart.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/client.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/codes.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/config.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/icon.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/pairing.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/pendify.ico +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/plate_almena.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/tray.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/update.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/watcher.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify/worker.py +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify.egg-info/SOURCES.txt +0 -0
- {pendify-0.1.5 → pendify-0.1.7}/src/pendify.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
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": "
|
|
53
|
-
"
|
|
54
|
-
"
|
|
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": "
|
|
145
|
-
"
|
|
146
|
-
"
|
|
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
|
|
675
|
-
|
|
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=
|
|
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["
|
|
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">{
|
|
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":
|