pendify 0.1.4__tar.gz → 0.1.5__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.4/src/pendify.egg-info → pendify-0.1.5}/PKG-INFO +7 -3
- {pendify-0.1.4 → pendify-0.1.5}/README.md +6 -2
- {pendify-0.1.4 → pendify-0.1.5}/pyproject.toml +1 -1
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/__main__.py +59 -7
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/config.py +18 -6
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/page.py +130 -25
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/qr.py +61 -10
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/tray.py +13 -7
- pendify-0.1.5/src/pendify/update.py +252 -0
- {pendify-0.1.4 → pendify-0.1.5/src/pendify.egg-info}/PKG-INFO +7 -3
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify.egg-info/SOURCES.txt +1 -0
- {pendify-0.1.4 → pendify-0.1.5}/LICENSE +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/MANIFEST.in +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/setup.cfg +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/__init__.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/alert.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/autostart.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/client.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/codes.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/icon.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/pairing.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/pendify.ico +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/plate_almena.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/runfile.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/watcher.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify/worker.py +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/src/pendify.egg-info/dependency_links.txt +0 -0
- {pendify-0.1.4 → pendify-0.1.5}/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.5
|
|
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
|
|
@@ -49,11 +49,13 @@ Si Windows responde que no encuentra `python`, usa `py` en su lugar: `py -m pip
|
|
|
49
49
|
|
|
50
50
|
Así no se crean los accesos directos en el Escritorio ni en el menú Inicio, y el inicio con Windows queda en el interruptor de la página. Para actualizar, pulsa «Salir» en la página, ejecuta otra vez la misma línea de pip y vuelve a iniciarlo. Para quitarlo, apaga primero el interruptor «Iniciar con Windows» en la página, pulsa «Salir» y después ejecuta `python -m pip uninstall pendify`. La configuración en `%APPDATA%\pendify`, con el enlace con el teléfono, se queda; puedes borrar esa carpeta a mano.
|
|
51
51
|
|
|
52
|
+
El programa revisa PyPI cada seis horas y, cuando hay una versión más nueva, la instala en segundo plano. La página ofrece entonces «Reiniciar ahora» para empezar a usarla. Para apagarlo, inícialo con `--no-update` o escribe `"update": "off"` en `%APPDATA%\pendify\config.json`. Las actualizaciones automáticas se instalan para tu usuario (--user).
|
|
53
|
+
|
|
52
54
|
### Enlazar
|
|
53
55
|
|
|
54
56
|
Al iniciarse, el navegador abre una página local con un código QR. Escanéalo con la cámara del teléfono: se abre la app Pendi y te pide confirmar el enlace. La página muestra cuando el enlace quedó hecho.
|
|
55
57
|
|
|
56
|
-
El código QR y la clave se muestran durante un minuto al pulsar «Mostrar el código
|
|
58
|
+
El código QR y la clave están ocultos; se muestran durante un minuto al pulsar «Mostrar el código», y la página dice para qué sirve el código.
|
|
57
59
|
|
|
58
60
|
El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el programa guarda la elección para la próxima vez.
|
|
59
61
|
|
|
@@ -112,11 +114,13 @@ If Windows answers that `python` was not found, use `py` in its place: `py -m pi
|
|
|
112
114
|
|
|
113
115
|
This way leaves no shortcut on the Desktop or in the Start menu, and the start with Windows is the switch on the page. To update, press «Quit» on the page, run the same pip line again and start it again. To remove it, first turn off the «Start with Windows» switch on the page, press «Quit», then run `python -m pip uninstall pendify`. The configuration in `%APPDATA%\pendify`, with the link to the phone, stays; you can delete that folder by hand.
|
|
114
116
|
|
|
117
|
+
The program checks PyPI every six hours and, when a newer version exists, installs it in the background. The page then offers «Restart now» to start using it. To turn this off, start it with `--no-update` or write `"update": "off"` in `%APPDATA%\pendify\config.json`. Automatic updates install for your user (--user).
|
|
118
|
+
|
|
115
119
|
### Link
|
|
116
120
|
|
|
117
121
|
When it starts, the browser opens a local page with a QR code. Scan it with the phone's camera: the Pendi app opens and asks you to confirm the link. The page shows when the link is done.
|
|
118
122
|
|
|
119
|
-
The QR code and the key show for a minute when «Show the code» is pressed, and
|
|
123
|
+
The QR code and the key are hidden; they show for a minute when «Show the code» is pressed, and the page says what the code is for.
|
|
120
124
|
|
|
121
125
|
The button at the top right switches between the light and the dark theme, and the program keeps the choice for the next time.
|
|
122
126
|
|
|
@@ -33,11 +33,13 @@ Si Windows responde que no encuentra `python`, usa `py` en su lugar: `py -m pip
|
|
|
33
33
|
|
|
34
34
|
Así no se crean los accesos directos en el Escritorio ni en el menú Inicio, y el inicio con Windows queda en el interruptor de la página. Para actualizar, pulsa «Salir» en la página, ejecuta otra vez la misma línea de pip y vuelve a iniciarlo. Para quitarlo, apaga primero el interruptor «Iniciar con Windows» en la página, pulsa «Salir» y después ejecuta `python -m pip uninstall pendify`. La configuración en `%APPDATA%\pendify`, con el enlace con el teléfono, se queda; puedes borrar esa carpeta a mano.
|
|
35
35
|
|
|
36
|
+
El programa revisa PyPI cada seis horas y, cuando hay una versión más nueva, la instala en segundo plano. La página ofrece entonces «Reiniciar ahora» para empezar a usarla. Para apagarlo, inícialo con `--no-update` o escribe `"update": "off"` en `%APPDATA%\pendify\config.json`. Las actualizaciones automáticas se instalan para tu usuario (--user).
|
|
37
|
+
|
|
36
38
|
### Enlazar
|
|
37
39
|
|
|
38
40
|
Al iniciarse, el navegador abre una página local con un código QR. Escanéalo con la cámara del teléfono: se abre la app Pendi y te pide confirmar el enlace. La página muestra cuando el enlace quedó hecho.
|
|
39
41
|
|
|
40
|
-
El código QR y la clave se muestran durante un minuto al pulsar «Mostrar el código
|
|
42
|
+
El código QR y la clave están ocultos; se muestran durante un minuto al pulsar «Mostrar el código», y la página dice para qué sirve el código.
|
|
41
43
|
|
|
42
44
|
El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el programa guarda la elección para la próxima vez.
|
|
43
45
|
|
|
@@ -96,11 +98,13 @@ If Windows answers that `python` was not found, use `py` in its place: `py -m pi
|
|
|
96
98
|
|
|
97
99
|
This way leaves no shortcut on the Desktop or in the Start menu, and the start with Windows is the switch on the page. To update, press «Quit» on the page, run the same pip line again and start it again. To remove it, first turn off the «Start with Windows» switch on the page, press «Quit», then run `python -m pip uninstall pendify`. The configuration in `%APPDATA%\pendify`, with the link to the phone, stays; you can delete that folder by hand.
|
|
98
100
|
|
|
101
|
+
The program checks PyPI every six hours and, when a newer version exists, installs it in the background. The page then offers «Restart now» to start using it. To turn this off, start it with `--no-update` or write `"update": "off"` in `%APPDATA%\pendify\config.json`. Automatic updates install for your user (--user).
|
|
102
|
+
|
|
99
103
|
### Link
|
|
100
104
|
|
|
101
105
|
When it starts, the browser opens a local page with a QR code. Scan it with the phone's camera: the Pendi app opens and asks you to confirm the link. The page shows when the link is done.
|
|
102
106
|
|
|
103
|
-
The QR code and the key show for a minute when «Show the code» is pressed, and
|
|
107
|
+
The QR code and the key are hidden; they show for a minute when «Show the code» is pressed, and the page says what the code is for.
|
|
104
108
|
|
|
105
109
|
The button at the top right switches between the light and the dark theme, and the program keeps the choice for the next time.
|
|
106
110
|
|
|
@@ -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.5"
|
|
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"
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
python -m <package> --dry the same, but the watcher never accepts: it alerts at the queue pop
|
|
5
5
|
python -m <package> --quiet the same, started by the system at logon: no browser, and no message box
|
|
6
6
|
unless the start fails
|
|
7
|
+
python -m <package> --no-update the same, with no check of PyPI for a newer version (update.py)
|
|
7
8
|
python -m <package> ping <kind> one alert with the stored pair, one fixed line per answer
|
|
8
9
|
|
|
9
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
|
|
10
11
|
the icon by the clock, which remove the run file and exit 0. One instance per config folder: a second start
|
|
11
12
|
opens the running page and exits 0, and one made while that program is still closing after its quit waits for
|
|
12
|
-
it to end and starts.
|
|
13
|
+
it to end and starts. A restart the page asks for («Reiniciar ahora», once a newer version is installed, or the
|
|
14
|
+
icon's «Reiniciar para actualizar») starts a new copy once the run file is released: without --quiet, so its page
|
|
15
|
+
opens, and with the --dry, --data-dir, --worker and --client-lockfile of this one.
|
|
13
16
|
Where no console is attached (a pythonw start), the line a start ends on is also shown in a
|
|
14
17
|
message box. `--data-dir`, `--worker` and `--client-lockfile` are the
|
|
15
18
|
test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
|
|
@@ -17,13 +20,14 @@ test overrides, loopback only, so a test run reads neither the profile, the Work
|
|
|
17
20
|
import argparse
|
|
18
21
|
import os
|
|
19
22
|
import signal
|
|
23
|
+
import subprocess
|
|
20
24
|
import sys
|
|
21
25
|
import threading
|
|
22
26
|
import time
|
|
23
27
|
import webbrowser
|
|
24
28
|
from pathlib import Path
|
|
25
29
|
|
|
26
|
-
from . import alert, client, config, page, pairing, runfile, watcher, worker
|
|
30
|
+
from . import alert, client, config, page, pairing, runfile, update, watcher, worker
|
|
27
31
|
from .autostart import Autostart
|
|
28
32
|
from .tray import Tray
|
|
29
33
|
|
|
@@ -41,8 +45,12 @@ PAGE_NOT_KNOWN_LINE = ("already running: the page of the program that runs is no
|
|
|
41
45
|
"moment to open it")
|
|
42
46
|
CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
|
|
43
47
|
FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
|
|
48
|
+
RESTART_FAILED_LINE = "cannot restart: the new copy did not start; start the program again"
|
|
44
49
|
_MB_ICONINFORMATION = 0x40
|
|
45
50
|
_MB_SETFOREGROUND = 0x10000
|
|
51
|
+
# The restart's new copy: no console, its own process group, out of this one's.
|
|
52
|
+
_DETACHED_PROCESS = 0x00000008
|
|
53
|
+
_CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
46
54
|
# The icon by the clock's picture: pendify.ico beside the modules, the file install.ps1 points the shortcuts at.
|
|
47
55
|
ICON_PATH = Path(__file__).with_name("pendify.ico")
|
|
48
56
|
|
|
@@ -82,6 +90,8 @@ def _arguments(argv):
|
|
|
82
90
|
parser.add_argument("--dry", action="store_true", help="watch and alert, but never accept")
|
|
83
91
|
parser.add_argument("--quiet", action="store_true",
|
|
84
92
|
help="a start by the system: no browser, and no message box for a start that ends well")
|
|
93
|
+
parser.add_argument("--no-update", action="store_true",
|
|
94
|
+
help="no check of PyPI for a newer version in this run")
|
|
85
95
|
commands = parser.add_subparsers(dest="command")
|
|
86
96
|
ping = commands.add_parser("ping", parents=[common], help="send one alert with the stored pair")
|
|
87
97
|
ping.add_argument("kind", choices=worker.KINDS)
|
|
@@ -154,7 +164,31 @@ def _ends(line, code, show):
|
|
|
154
164
|
return code
|
|
155
165
|
|
|
156
166
|
|
|
157
|
-
def
|
|
167
|
+
def _restart(args, spawn, show, opens_page):
|
|
168
|
+
"""The restart asked for, once the run file is free: a new copy of this program, detached and with no console,
|
|
169
|
+
which claims the run file as a first start does. One that opens its page, as every restart asked today does,
|
|
170
|
+
goes without --quiet; one that does not keeps this run's --quiet. This run's --dry, --data-dir, --worker and
|
|
171
|
+
--client-lockfile go with it either way."""
|
|
172
|
+
command = [sys.executable, "-m", __package__] + (["--quiet"] if args.quiet and not opens_page else [])
|
|
173
|
+
command += ["--dry"] if args.dry else []
|
|
174
|
+
for option in ("data_dir", "worker", "client_lockfile"):
|
|
175
|
+
if hasattr(args, option): # the parser's SUPPRESS: present only when this run was given it
|
|
176
|
+
command += ["--" + option.replace("_", "-"), str(getattr(args, option))]
|
|
177
|
+
try:
|
|
178
|
+
spawn(command, creationflags=_DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP, close_fds=True)
|
|
179
|
+
except OSError:
|
|
180
|
+
return _ends(RESTART_FAILED_LINE, 1, show)
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _ready_version(updater):
|
|
185
|
+
"""The version an install made ready, for the icon's item; None in any other state."""
|
|
186
|
+
seen = updater.snapshot()
|
|
187
|
+
return seen["version"] if seen["state"] == update.READY else None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray, updater,
|
|
191
|
+
spawn):
|
|
158
192
|
# A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
|
|
159
193
|
calm = (lambda line: None) if args.quiet else show
|
|
160
194
|
run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
|
|
@@ -179,7 +213,9 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
|
|
|
179
213
|
opener(f"http://{page.ADDRESS}:{port}/")
|
|
180
214
|
show(ALREADY_RUNNING_LINE)
|
|
181
215
|
return 0
|
|
216
|
+
updating = None
|
|
182
217
|
try:
|
|
218
|
+
mode = update.OFF if args.no_update else store.read_update()
|
|
183
219
|
state = pairing.PairingState(store, lambda secret: worker.check(secret, base=base, timeout=timeout))
|
|
184
220
|
watch, alerter = _watcher(args, store, state, base, timeout, stop, delay, beep)
|
|
185
221
|
|
|
@@ -196,14 +232,22 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
|
|
|
196
232
|
pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
|
|
197
233
|
on_resume=watch.resume, autostart=autostart, events=watch.events)
|
|
198
234
|
url = pairing_page.start()
|
|
235
|
+
# The update's rounds on their own thread (update.py), built once the page serves; its restart is the
|
|
236
|
+
# page's quit, after the flag read below once the run file is free; pip's output goes to the config folder.
|
|
237
|
+
updating = updater(update.RUNNING_VERSION, mode, restart=quit_page, folder=store.path.parent)
|
|
238
|
+
pairing_page.updater = updating
|
|
199
239
|
# The icon by the clock, under --quiet as well: the way to stop a copy the Run key started. Its words are
|
|
200
240
|
# the page's, in the language the page resolves with no browser to ask: the kept choice, else Spanish.
|
|
201
241
|
icon = tray(url, on_open=lambda: opener(url), on_pause=watch.pause, on_resume=watch.resume,
|
|
202
242
|
on_quit=quit_page, paused=lambda: watch.paused,
|
|
203
|
-
words=lambda: page.WORDS[pairing_page.language_of(None)], icon_path=ICON_PATH
|
|
243
|
+
words=lambda: page.WORDS[pairing_page.language_of(None)], icon_path=ICON_PATH,
|
|
244
|
+
# «Reiniciar para actualizar a X» once an install is ready, the page's own restart
|
|
245
|
+
update_ready=lambda: _ready_version(updating), on_restart=updating.restart)
|
|
204
246
|
watching = threading.Thread(target=watch.run, daemon=True)
|
|
205
247
|
try:
|
|
206
248
|
icon.start()
|
|
249
|
+
if mode != update.OFF:
|
|
250
|
+
updating.start()
|
|
207
251
|
run.publish(pairing_page.port)
|
|
208
252
|
print(f"page: {url}", flush=True)
|
|
209
253
|
if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
|
|
@@ -217,6 +261,7 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
|
|
|
217
261
|
pass
|
|
218
262
|
finally:
|
|
219
263
|
stop.set()
|
|
264
|
+
updating.close() # the restart gate shuts at the quit
|
|
220
265
|
# First, so a start made while this one stops waits for it instead of opening a closing page: Ctrl+C
|
|
221
266
|
# and Ctrl+Break are marked only here, and the page's quit, marked already, is marked again.
|
|
222
267
|
try:
|
|
@@ -228,8 +273,13 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
|
|
|
228
273
|
alerter.flush(STOP_SECONDS)
|
|
229
274
|
pairing_page.close()
|
|
230
275
|
icon.close()
|
|
276
|
+
# An install under way is waited for before the run file goes, so a quit does not end this copy while
|
|
277
|
+
# pip swaps the files; past the bound pip goes on alone, its output in a file.
|
|
278
|
+
updating.wait(update.INSTALL_JOIN_SECONDS)
|
|
231
279
|
finally:
|
|
232
280
|
run.release()
|
|
281
|
+
if updating is not None and updating.restart_requested.is_set():
|
|
282
|
+
return _restart(args, spawn, show, updating.restart_opens_page)
|
|
233
283
|
return 0
|
|
234
284
|
|
|
235
285
|
|
|
@@ -242,11 +292,13 @@ def _break_as_interrupt():
|
|
|
242
292
|
|
|
243
293
|
def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
|
|
244
294
|
beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
|
|
245
|
-
sleep=time.sleep, tray=Tray):
|
|
295
|
+
sleep=time.sleep, tray=Tray, updater=update.Updater, spawn=subprocess.Popen):
|
|
246
296
|
"""`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
|
|
247
297
|
(the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
|
|
248
298
|
line reaches anybody; `clock` and `sleep` time the run file's re-reads. `tray` makes the icon by the clock, the
|
|
249
|
-
seam a test run fills with a fake, so no test shows a real icon.
|
|
299
|
+
seam a test run fills with a fake, so no test shows a real icon. `updater` makes the update and `spawn` starts
|
|
300
|
+
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."""
|
|
250
302
|
args = _arguments(sys.argv[1:] if argv is None else argv)
|
|
251
303
|
store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
|
|
252
304
|
base = getattr(args, "worker", worker.BASE_URL)
|
|
@@ -262,7 +314,7 @@ def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT
|
|
|
262
314
|
try:
|
|
263
315
|
return _serve(args, store, base, timeout, opener, stop or threading.Event(),
|
|
264
316
|
delay or watcher.accept_delay, beep, show, clock, sleep,
|
|
265
|
-
autostart if autostart is not None else Autostart(), tray)
|
|
317
|
+
autostart if autostart is not None else Autostart(), tray, updater, spawn)
|
|
266
318
|
finally:
|
|
267
319
|
if previous is not None:
|
|
268
320
|
signal.signal(signal.SIGBREAK, previous)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The config file: the secret and the link id, under the user's profile (design P1, P6), and the page's theme
|
|
2
2
|
choice when there is one (light or dark; the system's choice is no key), and its language choice when there is one
|
|
3
|
-
(es or en; with none the page follows the browser)
|
|
3
|
+
(es or en; with none the page follows the browser), and the update's mode when one is written there by hand
|
|
4
|
+
(auto, notify or off; with none, auto).
|
|
4
5
|
|
|
5
6
|
The file is `<base>/<folder>/config.json`, where the base is %APPDATA% in a real run and is injected
|
|
6
7
|
everywhere else, so no test touches the real profile. A write goes to a temp file in the same folder
|
|
@@ -30,6 +31,10 @@ THEMES = ("light", "dark")
|
|
|
30
31
|
SYSTEM_THEME = "system"
|
|
31
32
|
# The language switch's choices; with no key the page follows the browser's language.
|
|
32
33
|
LANGS = ("es", "en")
|
|
34
|
+
# The update's modes (update.py), written by hand as the key "update": auto checks PyPI and installs in the
|
|
35
|
+
# background, notify checks only, off never checks. With no key, or a foreign value, auto.
|
|
36
|
+
UPDATE_MODES = ("auto", "notify", "off")
|
|
37
|
+
DEFAULT_UPDATE = "auto"
|
|
33
38
|
# _write's default: the stored theme and language choices are kept.
|
|
34
39
|
_KEEP = object()
|
|
35
40
|
|
|
@@ -92,6 +97,11 @@ class ConfigStore:
|
|
|
92
97
|
and cannot be read raises ConfigError, as read_theme() does; a missing, corrupt or foreign value is None."""
|
|
93
98
|
return _choice(self._stored(), "lang", LANGS)
|
|
94
99
|
|
|
100
|
+
def read_update(self):
|
|
101
|
+
"""The update's mode, auto, notify or off; auto when the file holds none or a foreign one. A file that is
|
|
102
|
+
there and cannot be read raises ConfigError, as read_theme() does."""
|
|
103
|
+
return _choice(self._stored(), "update", UPDATE_MODES) or DEFAULT_UPDATE
|
|
104
|
+
|
|
95
105
|
def set_theme(self, choice):
|
|
96
106
|
"""The theme button's choice written beside the pair: light or dark kept, system kept as no key. Any
|
|
97
107
|
other value is refused and nothing is written. Answers the stored choice, None for the system's."""
|
|
@@ -176,17 +186,19 @@ class ConfigStore:
|
|
|
176
186
|
|
|
177
187
|
def _write(self, pairing, theme=_KEEP, lang=_KEEP):
|
|
178
188
|
"""The pair, and the theme and language choices: each stored one kept unless set_theme or set_lang passes
|
|
179
|
-
its own."""
|
|
189
|
+
its own. The update's mode, written by hand, is kept as it is stored."""
|
|
180
190
|
folder = self.path.parent
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
191
|
+
stored = self._stored()
|
|
192
|
+
theme = _choice(stored, "theme", THEMES) if theme is _KEEP else theme
|
|
193
|
+
lang = _choice(stored, "lang", LANGS) if lang is _KEEP else lang
|
|
194
|
+
update = _choice(stored, "update", UPDATE_MODES)
|
|
185
195
|
values = {"secret": pairing.secret, "linkId": pairing.link_id}
|
|
186
196
|
if theme is not None:
|
|
187
197
|
values["theme"] = theme
|
|
188
198
|
if lang is not None:
|
|
189
199
|
values["lang"] = lang
|
|
200
|
+
if update is not None:
|
|
201
|
+
values["update"] = update
|
|
190
202
|
data = json.dumps(values).encode("utf-8")
|
|
191
203
|
try:
|
|
192
204
|
folder.mkdir(parents=True, exist_ok=True)
|
|
@@ -15,7 +15,7 @@ import time
|
|
|
15
15
|
import urllib.parse
|
|
16
16
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
17
17
|
|
|
18
|
-
from . import codes, config, icon, plate_almena, qr
|
|
18
|
+
from . import codes, config, icon, plate_almena, qr, update
|
|
19
19
|
|
|
20
20
|
ADDRESS = "127.0.0.1"
|
|
21
21
|
MAX_FORM_BYTES = 4096
|
|
@@ -48,8 +48,10 @@ WORDS = {
|
|
|
48
48
|
"title": "Avisos de este PC",
|
|
49
49
|
"intro": "Este programa envía sus avisos a tu cuenta de Pendi. Para eso, este PC tiene que quedar enlazado "
|
|
50
50
|
"con tu cuenta.",
|
|
51
|
-
"scan": "Escanea este código con la cámara del teléfono donde tienes tu cuenta y confirma el enlace.",
|
|
52
51
|
"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
55
|
"show_code": "Mostrar el código",
|
|
54
56
|
"hide_code": "Ocultar el código",
|
|
55
57
|
"mask": "Clave oculta",
|
|
@@ -125,13 +127,23 @@ WORDS = {
|
|
|
125
127
|
"stopped": "El programa se detuvo: ya no vigila el cliente del juego ni envía avisos.",
|
|
126
128
|
"start_again": "Para volver a iniciarlo, abre el acceso directo del Escritorio o ejecuta "
|
|
127
129
|
"pythonw -m pendify (o python -m pendify para verlo en una consola).",
|
|
130
|
+
"update_available": "Hay una versión nueva: {version}",
|
|
131
|
+
"update_install": "Actualizar",
|
|
132
|
+
"update_installing": "Instalando la versión {version}…",
|
|
133
|
+
"update_ready": "Actualización lista: {version}. Se aplica al reiniciar.",
|
|
134
|
+
"update_restart": "Reiniciar ahora",
|
|
135
|
+
"update_failed": "No se pudo instalar la versión {version}",
|
|
136
|
+
"update_restarting": "Reiniciando. La página nueva se abre en unos segundos.",
|
|
137
|
+
"update_tray_restart": "Reiniciar para actualizar a {version}",
|
|
128
138
|
},
|
|
129
139
|
"en": {
|
|
130
140
|
"title": "Alerts from this PC",
|
|
131
141
|
"intro": "This program sends its alerts to your Pendi account. For that, this PC has to be linked to your "
|
|
132
142
|
"account.",
|
|
133
|
-
"scan": "Scan this code with the camera of the phone that holds your account and confirm the link.",
|
|
134
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",
|
|
135
147
|
"show_code": "Show the code",
|
|
136
148
|
"hide_code": "Hide the code",
|
|
137
149
|
"mask": "Key hidden",
|
|
@@ -207,6 +219,14 @@ WORDS = {
|
|
|
207
219
|
"stopped": "The program stopped: it no longer watches the game client or sends alerts.",
|
|
208
220
|
"start_again": "To start it again, open the shortcut on the Desktop or run pythonw -m pendify "
|
|
209
221
|
"(or python -m pendify to see it in a console).",
|
|
222
|
+
"update_available": "A new version is out: {version}",
|
|
223
|
+
"update_install": "Update",
|
|
224
|
+
"update_installing": "Installing version {version}…",
|
|
225
|
+
"update_ready": "Update ready: {version}. It applies on restart.",
|
|
226
|
+
"update_restart": "Restart now",
|
|
227
|
+
"update_failed": "Version {version} could not be installed",
|
|
228
|
+
"update_restarting": "Restarting. The new page opens in a few seconds.",
|
|
229
|
+
"update_tray_restart": "Restart to update to {version}",
|
|
210
230
|
},
|
|
211
231
|
}
|
|
212
232
|
|
|
@@ -264,10 +284,9 @@ _STYLE = (":root{color-scheme:light dark;" + _LIGHT + "}\n"
|
|
|
264
284
|
'#state[data-shown="falsetrue"]{background:var(--danger-bg);color:var(--on-danger-bg)}\n'
|
|
265
285
|
'#state[data-shown="falsetrue"]::before{background:none;border:2px solid currentColor}\n'
|
|
266
286
|
"#watch{margin:12px 0 0;max-width:40rem;font-size:14px;line-height:20px;color:var(--ink2)}\n"
|
|
267
|
-
".scan{margin:24px 0 16px;max-width:40ch}\n"
|
|
268
287
|
".note{margin:12px 0 0;font-size:14px;line-height:20px;color:var(--ink2)}\n"
|
|
269
288
|
".note.warn{color:var(--danger)}\n"
|
|
270
|
-
".key-card{display:grid;justify-items:center;gap:16px;margin:
|
|
289
|
+
".key-card{display:grid;justify-items:center;gap:16px;margin:24px 0 16px;padding:20px;border-radius:16px;"
|
|
271
290
|
"background:var(--card);border:1px solid var(--hair);box-shadow:var(--shadow)}\n"
|
|
272
291
|
".qr{display:block;width:100%;max-width:288px;height:auto;border-radius:12px;"
|
|
273
292
|
"box-shadow:0 0 0 1px var(--hair)}\n"
|
|
@@ -378,7 +397,7 @@ _STYLE = (":root{color-scheme:light dark;" + _LIGHT + "}\n"
|
|
|
378
397
|
# keyframes inside the reduced-motion guard with the rules that run them.
|
|
379
398
|
# Width: 85 % of the window at every desktop width, never narrower than 1040 px, no upper cap; the code's column grows
|
|
380
399
|
# with the window and the code with it, up to 480 px. Column 1 is one measure: two tracks and a 24 px gutter, every
|
|
381
|
-
# block spans both and the boxes lose their 40rem cap, while the
|
|
400
|
+
# block spans both and the boxes lose their 40rem cap, while the intro keeps its own (.intro 46ch).
|
|
382
401
|
# From a 1600 px window «Este PC» and «Actividad» share one row, a track each, as the two panels of the fold do; dense
|
|
383
402
|
# placement draws the log card beside «Este PC» while the markup keeps its order, so the narrow page is unchanged.
|
|
384
403
|
_STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
|
|
@@ -392,12 +411,19 @@ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
|
|
|
392
411
|
".key-card .qr{max-width:480px}}\n"
|
|
393
412
|
"@media (min-width:1600px){.link:has(>.pc):has(>.log-card)>.pc{grid-column:1;align-content:start}\n"
|
|
394
413
|
".link:has(>.pc):has(>.log-card)>.log-card{grid-column:3;margin-top:24px}}\n"
|
|
395
|
-
# The code is
|
|
414
|
+
# The code is hidden until asked (owner 2026-10-02, after his 2026-10-01 ruling to show it): the key and
|
|
415
|
+
# the QR's modules stay hidden until «Mostrar el código», the modules masked by a pixelated figure as the
|
|
416
|
+
# key is by its heads (the script sets data-shown on the key; the card's rules swap both from it); the
|
|
417
|
+
# drawings, the plate and the party with their moves, are always visible (owner 2026-10-02 10:3x). The
|
|
418
|
+
# label over the reveal is the QR's one sentence and its accessible name.
|
|
396
419
|
".key{justify-items:center;text-align:center}\n"
|
|
397
420
|
".key-val{display:grid;place-items:center;min-height:32px}\n"
|
|
398
421
|
".key-val>*{grid-area:1/1}\n"
|
|
399
422
|
'.key[data-shown="false"] .code{visibility:hidden}\n'
|
|
400
423
|
'.key[data-shown="true"] .mask{visibility:hidden}\n'
|
|
424
|
+
'.key-card:has(.key[data-shown="false"]) .modules{visibility:hidden}\n'
|
|
425
|
+
'.key-card:has(.key[data-shown="true"]) .qr-mask{visibility:hidden}\n'
|
|
426
|
+
".qr-for{margin:0}\n"
|
|
401
427
|
".mask{display:flex;align-items:flex-end;gap:10px}\n"
|
|
402
428
|
".mask .grp{display:flex;align-items:flex-end;gap:2px}\n"
|
|
403
429
|
".mask svg{display:block;flex:none}\n"
|
|
@@ -483,20 +509,37 @@ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
|
|
|
483
509
|
# a page that draws the switch only, against the switch as it was rendered (defaultChecked, from enabled(), the read
|
|
484
510
|
# /state answers, so a reload cannot loop, and a poll landing between a change and its post's answer cannot reload
|
|
485
511
|
# the page under the post); the language too, against the one the page was drawn in (<html lang>), so a second tab
|
|
486
|
-
# or another browser turns to a language chosen with the switch;
|
|
512
|
+
# or another browser turns to a language chosen with the switch; and the update's state and version, against the
|
|
513
|
+
# ones the page was drawn with (data-update, served only beside an updater), so the update's line is drawn again
|
|
514
|
+
# as it moves; says so when the program is gone.
|
|
487
515
|
# While the program is gone the state line's data-shown is a value no style rule names, so the look of what the page
|
|
488
516
|
# showed (the linked page's check mark) never sits beside the closed sentence; an answer puts the load value back.
|
|
489
517
|
_SCRIPT = ("const s=document.getElementById('state');const w=document.getElementById('watch');"
|
|
490
|
-
"const shown=s.dataset.shown;const paused=s.dataset.paused;const
|
|
518
|
+
"const shown=s.dataset.shown;const paused=s.dataset.paused;const upd=s.dataset.update;"
|
|
519
|
+
"const lang=document.documentElement.lang;"
|
|
491
520
|
"const sw=document.querySelector('input[name=autostart]');"
|
|
492
521
|
# The card «Actividad»'s list is a tab stop only while its lines are taller than its box, read at load and
|
|
493
522
|
# after each insert (the design review's open item 3: a stop on a list that does not scroll is dead).
|
|
494
523
|
"const g=document.querySelector('.log');function tabStop(){if(g.scrollHeight>g.clientHeight)"
|
|
495
524
|
"g.setAttribute('tabindex','0');else g.removeAttribute('tabindex');}if(g)tabStop();"
|
|
496
|
-
|
|
525
|
+
# The restart (pfupd round 2): «Reiniciar ahora» posted by the script, its answer, the restart's words,
|
|
526
|
+
# drawn in place of the state line; once asked, a poll that answers changes nothing and one that fails
|
|
527
|
+
# keeps the line. A page drawn while «Actualizar»'s install runs carries the words (data-restarting), so the
|
|
528
|
+
# restart that follows reads the same, and a poll that finds it ready draws them at once instead of
|
|
529
|
+
# reloading a page that is stopping.
|
|
530
|
+
"const follows=s.dataset.restarting!==undefined;let asked=false;"
|
|
531
|
+
"function restarting(t){asked=true;s.textContent=t;s.dataset.shown='closed';}"
|
|
532
|
+
"const rf=document.querySelector('form[action=\"/restart\"]');"
|
|
533
|
+
"if(rf)rf.addEventListener('submit',e=>{e.preventDefault();"
|
|
534
|
+
"fetch('/restart',{method:'POST',body:new URLSearchParams(new FormData(rf))})"
|
|
535
|
+
".then(r=>r.ok?r.text():Promise.reject(r.status)).then(t=>{restarting(t);}).catch(()=>{});});"
|
|
536
|
+
"setInterval(()=>fetch('/state').then(r=>r.json()).then(j=>{if(asked)return;"
|
|
537
|
+
"if(follows&&j.update&&j.update.state==='ready'){restarting(s.dataset.restarting);return;}"
|
|
538
|
+
"s.textContent=j.text;s.dataset.shown=shown;"
|
|
497
539
|
"if(w&&j.watchText)w.textContent=j.watchText;"
|
|
498
540
|
"if(String(j.showCode)+String(j.relinkOffered)!==shown||(paused!==undefined&&String(j.paused)!==paused)"
|
|
499
|
-
"||j.lang!==lang||(
|
|
541
|
+
"||j.lang!==lang||(upd!==undefined&&j.update&&j.update.state+' '+(j.update.version||'')!==upd)"
|
|
542
|
+
"||(sw&&j.autostart!==undefined&&String(j.autostart)!==String(sw.defaultChecked)))"
|
|
500
543
|
"location.reload();"
|
|
501
544
|
# The log's lines above the highest seq the card has drawn go on top, oldest first so the newest ends
|
|
502
545
|
# first, built as elements with their text (never as markup); the card keeps the last 50, as the ring
|
|
@@ -507,7 +550,8 @@ _SCRIPT = ("const s=document.getElementById('state');const w=document.getElement
|
|
|
507
550
|
"const x=document.createElement('span');if(l.warn)x.className='warn';x.textContent=l.text;"
|
|
508
551
|
"li.append(t,x);o.insertBefore(li,o.firstChild);last=l.seq;}g.dataset.seq=String(last);"
|
|
509
552
|
"while(o.children.length>50)o.lastElementChild.remove();tabStop();}})"
|
|
510
|
-
".catch(()=>{s.textContent=s.dataset.
|
|
553
|
+
".catch(()=>{if(asked)return;s.textContent=follows?s.dataset.restarting:s.dataset.closed;"
|
|
554
|
+
"s.dataset.shown='closed';}),5000);"
|
|
511
555
|
# The key shows for a minute: the reveal sets data-shown on the key, which swaps the mask for the code, and
|
|
512
556
|
# flips its own label; 60 s after a show the key is masked again; one timer, cleared on every press, so a
|
|
513
557
|
# hide by hand leaves none running. The sheet's function is named showKey here, since the script's first
|
|
@@ -619,9 +663,11 @@ _MASK_GROUP = ('<span class="grp">' + "".join('<svg class="mx" viewBox="0 0 8 8"
|
|
|
619
663
|
for rows in _HEADS) + "</span>")
|
|
620
664
|
|
|
621
665
|
|
|
622
|
-
# With scripts blocked the reveal does nothing, so the key reads as it did before the mask: the code
|
|
623
|
-
# and the reveal gone.
|
|
624
|
-
|
|
666
|
+
# With scripts blocked the reveal does nothing, so the key reads as it did before the mask: the code and the QR's
|
|
667
|
+
# modules shown, the key's mask, the QR's mask and the reveal gone. Each rule repeats its hiding rule's own selector
|
|
668
|
+
# and comes later, so it wins.
|
|
669
|
+
_NOSCRIPT_STYLE = ('.key[data-shown="false"] .code,.key-card:has(.key[data-shown="false"]) .modules'
|
|
670
|
+
'{visibility:visible}.mask,.qr-mask,.reveal{display:none}')
|
|
625
671
|
|
|
626
672
|
|
|
627
673
|
def _mask(words):
|
|
@@ -718,7 +764,8 @@ def _switch(words, token, lang):
|
|
|
718
764
|
|
|
719
765
|
|
|
720
766
|
class PairingPage:
|
|
721
|
-
def __init__(self, state, watch=None, on_quit=None, on_pause=None, on_resume=None, autostart=None, events=None
|
|
767
|
+
def __init__(self, state, watch=None, on_quit=None, on_pause=None, on_resume=None, autostart=None, events=None,
|
|
768
|
+
updater=None):
|
|
722
769
|
"""`watch` answers the watcher's snapshot; without it the page shows no watcher line. `on_quit` stops
|
|
723
770
|
the program; without it the page shows no quit button and /quit is no route. `on_pause` and `on_resume`
|
|
724
771
|
pause and resume the watcher; without them /pause and /resume are no routes. `autostart` is the start
|
|
@@ -726,13 +773,16 @@ class PairingPage:
|
|
|
726
773
|
it. Beside a watcher the page draws the card «Este PC» after the watcher line: the pause, or the resume while
|
|
727
774
|
paused, and the switch of the start with Windows where it is available. `events` answers the watcher's
|
|
728
775
|
events, oldest first; with it /state carries the log's lines and the page draws them in the card «Actividad»,
|
|
729
|
-
without it no log key and no card.
|
|
776
|
+
without it no log key and no card. `updater` is the update (update.py), set by main once the page serves;
|
|
777
|
+
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."""
|
|
730
779
|
self.state = state
|
|
731
780
|
self.watch = watch
|
|
732
781
|
self.on_quit = on_quit
|
|
733
782
|
self.on_pause, self.on_resume = on_pause, on_resume
|
|
734
783
|
self.autostart = autostart if autostart is not None and autostart.available else None
|
|
735
784
|
self.events = events
|
|
785
|
+
self.updater = updater
|
|
736
786
|
self.token = secrets.token_urlsafe(32)
|
|
737
787
|
self.server = None
|
|
738
788
|
self.port = None
|
|
@@ -840,6 +890,8 @@ class PairingPage:
|
|
|
840
890
|
answer["autostart"] = self.autostart.enabled()
|
|
841
891
|
if self.events is not None:
|
|
842
892
|
answer["log"] = self._log_lines(self.events(), lang)
|
|
893
|
+
if self.updater is not None:
|
|
894
|
+
answer["update"] = self.updater.snapshot()
|
|
843
895
|
return answer
|
|
844
896
|
|
|
845
897
|
def render(self, lang):
|
|
@@ -850,14 +902,25 @@ class PairingPage:
|
|
|
850
902
|
token = html.escape(self.token)
|
|
851
903
|
# Beside a watcher the state line carries the pause it was rendered with, which the poll compares.
|
|
852
904
|
paused = "" if seen is None else f' data-paused="{str(_paused(seen)).lower()}"'
|
|
905
|
+
# Beside an updater it carries the update's state and version as well, read once for the line below.
|
|
906
|
+
updated = self.updater.snapshot() if self.updater is not None else None
|
|
907
|
+
updating = "" if updated is None else (f' data-update="{updated["state"]} '
|
|
908
|
+
f'{html.escape(updated["version"] or "")}"')
|
|
909
|
+
# While «Actualizar»'s install runs in notify mode, and once it reads ready, the restart follows by itself:
|
|
910
|
+
# the state line carries its words, which the script draws once the program is gone.
|
|
911
|
+
follows = (updated is not None and self.updater.mode == update.NOTIFY
|
|
912
|
+
and updated["state"] in (update.INSTALLING, update.READY))
|
|
913
|
+
restarting = f' data-restarting="{words["update_restarting"]}"' if follows else ""
|
|
853
914
|
# What this is and what to do, with the QR card beside it on a wide window; the two other roads in one fold
|
|
854
915
|
# under it; the credit opens the top bar, and the quit sits at the foot, only beside a quit. The title row reads
|
|
855
916
|
# the program's name, its sentence under it.
|
|
856
917
|
link = [f'<div class="title-row"><img alt="" width="32" height="32" src="{ICON_URI}"><h1>{NAME}</h1>'
|
|
857
918
|
f"</div>", f'<p class="tagline">{words["title"]}</p>', f'<p class="intro">{words["intro"]}</p>',
|
|
858
919
|
f'<p id="state" role="status" data-shown="{str(snapshot["showCode"]).lower()}'
|
|
859
|
-
f'{str(snapshot["relinkOffered"]).lower()}"{paused}
|
|
920
|
+
f'{str(snapshot["relinkOffered"]).lower()}"{paused}{updating}{restarting} '
|
|
921
|
+
f'data-closed="{words["state_closed"]}">'
|
|
860
922
|
f'{words[_state_word(snapshot, seen)]}</p>']
|
|
923
|
+
link += self._update_line(words, token, updated)
|
|
861
924
|
if snapshot["configFailed"]:
|
|
862
925
|
link.append(f'<p class="note warn">{words["config_failed"]}</p>')
|
|
863
926
|
if seen is not None:
|
|
@@ -874,16 +937,17 @@ class PairingPage:
|
|
|
874
937
|
link.append(log_card)
|
|
875
938
|
if secret is not None:
|
|
876
939
|
modules = qr.encode(qr.pairing_address(secret).encode("ascii")).modules
|
|
877
|
-
drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="
|
|
940
|
+
drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="qr-for", mask=words["qr_mask"])
|
|
878
941
|
if drawn is None: # the scene is drawn for version 3 only
|
|
879
|
-
drawn = qr.svg(modules, labelledby="
|
|
880
|
-
link += [
|
|
881
|
-
#
|
|
882
|
-
#
|
|
883
|
-
# again 60 s after a show.
|
|
942
|
+
drawn = qr.svg(modules, labelledby="qr-for", mask=words["qr_mask"])
|
|
943
|
+
link += [# The code is hidden until asked (owner 2026-10-02): the QR's modules under their pixelated
|
|
944
|
+
# mask and the key's text under its heads until its person presses the reveal, a real button
|
|
945
|
+
# whose label says what a press does, with the label of what the QR is for over it, the QR's
|
|
946
|
+
# one sentence and its name; _SCRIPT hides both again 60 s after a show. The drawings stay.
|
|
884
947
|
f'<figure class="key-card">{drawn}<p class="key" data-shown="false"><span class="key-label">'
|
|
885
948
|
f'{words["code_label"]}</span> <span class="key-val">{_mask(words)}<span class="code" id="code">'
|
|
886
|
-
f'{codes.display(secret)}</span></span></p><
|
|
949
|
+
f'{codes.display(secret)}</span></span></p><p class="qr-for" id="qr-for">{words["qr_for"]}</p>'
|
|
950
|
+
f'<button type="button" class="reveal" '
|
|
887
951
|
f'aria-controls="code" data-show="{words["show_code"]}" data-hide="{words["hide_code"]}">'
|
|
888
952
|
f'{words["show_code"]}</button>{_PARTY}</figure>',
|
|
889
953
|
_form("check", token, f'<button type="submit">{words["check"]}</button>')]
|
|
@@ -930,6 +994,33 @@ class PairingPage:
|
|
|
930
994
|
f"</style><noscript><style>{_NOSCRIPT_STYLE}</style></noscript><script>{_THEME_READ}</script></head>"
|
|
931
995
|
f"<body>{body}<script>{_SCRIPT}</script></body></html>")
|
|
932
996
|
|
|
997
|
+
def _update_line(self, words, token, seen):
|
|
998
|
+
"""The update's line under the state line and its one button, by state: a version seen in notify mode with
|
|
999
|
+
«Actualizar», a version ready in either mode with «Reiniciar ahora», an install under way alone, a failed one
|
|
1000
|
+
with pip's last line in its title; nothing for none, nor for a version auto mode is about to install.
|
|
1001
|
+
`words` are escaped already."""
|
|
1002
|
+
if seen is None:
|
|
1003
|
+
return []
|
|
1004
|
+
state, version = seen["state"], html.escape(seen["version"] or "")
|
|
1005
|
+
if state == update.AVAILABLE and self.updater.mode == update.NOTIFY:
|
|
1006
|
+
key, button = "update_available", ("update", "update_install")
|
|
1007
|
+
elif state == update.READY:
|
|
1008
|
+
key, button = "update_ready", ("restart", "update_restart")
|
|
1009
|
+
elif state == update.INSTALLING:
|
|
1010
|
+
key, button = "update_installing", None
|
|
1011
|
+
elif state == update.FAILED:
|
|
1012
|
+
key, button = "update_failed", None
|
|
1013
|
+
else:
|
|
1014
|
+
return []
|
|
1015
|
+
failed = state == update.FAILED
|
|
1016
|
+
title = f' title="{html.escape(seen["error"] or "")}"' if failed else ""
|
|
1017
|
+
drawn = [f'<p id="update" class="note{" warn" if failed else ""}"{title}>'
|
|
1018
|
+
f'{words[key].format(version=version)}</p>']
|
|
1019
|
+
if button is not None:
|
|
1020
|
+
action, label = button
|
|
1021
|
+
drawn.append(_form(action, token, f'<button type="submit">{words[label]}</button>'))
|
|
1022
|
+
return drawn
|
|
1023
|
+
|
|
933
1024
|
def _card(self, words, token, paused):
|
|
934
1025
|
"""The card «Este PC» (placement A, frames A1 to A6): the pause form, or the resume form while paused, then,
|
|
935
1026
|
where the start with Windows is available, its switch under a hairline, checked when the Run value exists.
|
|
@@ -1096,6 +1187,7 @@ def _handler(page):
|
|
|
1096
1187
|
routes += ("/pause",) if page.on_pause is not None else ()
|
|
1097
1188
|
routes += ("/resume",) if page.on_resume is not None else ()
|
|
1098
1189
|
routes += ("/autostart",) if page.autostart is not None else ()
|
|
1190
|
+
routes += ("/update", "/restart") if page.updater is not None else ()
|
|
1099
1191
|
if path not in routes:
|
|
1100
1192
|
return self._refuse(404)
|
|
1101
1193
|
if length is None: # chunked or absent is 411, anything but plain digits 400
|
|
@@ -1119,6 +1211,19 @@ def _handler(page):
|
|
|
1119
1211
|
finally:
|
|
1120
1212
|
page.on_quit()
|
|
1121
1213
|
return
|
|
1214
|
+
if path == "/restart":
|
|
1215
|
+
# «Reiniciar ahora», posted by the page's script: answered before the stop, as /quit is, with the
|
|
1216
|
+
# restart's words, which the script draws in place of the state line and keeps once the program is
|
|
1217
|
+
# gone, while the new copy starts and opens its own page; the flag, then the quit.
|
|
1218
|
+
try:
|
|
1219
|
+
self._send(200, "text/plain; charset=utf-8",
|
|
1220
|
+
WORDS[page.language_of(self.headers.get("Accept-Language"))]["update_restarting"])
|
|
1221
|
+
finally:
|
|
1222
|
+
page.updater.restart()
|
|
1223
|
+
return
|
|
1224
|
+
if path == "/update": # «Actualizar» in notify mode; in any other mode or state nothing is installed
|
|
1225
|
+
page.updater.request_install()
|
|
1226
|
+
return self._send(303, "text/plain; charset=utf-8", "", (("Location", "/"),))
|
|
1122
1227
|
if path == "/theme": # the page's script posts it and reads no page back
|
|
1123
1228
|
outcome = page.set_theme(form.get("theme", ""))
|
|
1124
1229
|
if outcome == "refused":
|
|
@@ -223,7 +223,42 @@ def penalty(modules):
|
|
|
223
223
|
+ 10 * (abs(dark * 2 - total) * 10 // total))
|
|
224
224
|
|
|
225
225
|
|
|
226
|
-
|
|
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.#+.#")
|
|
237
|
+
|
|
238
|
+
|
|
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"]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _finder(x, y):
|
|
245
|
+
"""A finder as svg() draws it: a violet 7 by 7 ring, a white 5 by 5 separator and a 3 by 3 eye."""
|
|
246
|
+
return (f'<rect x="{x}" y="{y}" width="7" height="7" rx="1.5" fill="{_QR_RING}"/>'
|
|
247
|
+
f'<rect x="{x + 1}" y="{y + 1}" width="5" height="5" rx="1.4" fill="{_QR_TILE}"/>'
|
|
248
|
+
f'<rect x="{x + 2}" y="{y + 2}" width="3" height="3" rx="1" fill="{_QR_INK}"/>')
|
|
249
|
+
|
|
250
|
+
|
|
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):
|
|
227
262
|
"""An inline SVG of the symbol on a white tile that keeps a four-module quiet zone, one unit per module.
|
|
228
263
|
|
|
229
264
|
The grid is the encoder's; only how a module is painted changes. A dark data module is a 0.88 dot with
|
|
@@ -231,6 +266,10 @@ def svg(modules, labelledby=None):
|
|
|
231
266
|
eye; each alignment pattern a 5 by 5 ring, its 3 by 3 gap and its centre. Every rounded corner is small
|
|
232
267
|
enough that each module's centre, where a reader samples, keeps the module's colour: a 7 by 7 ring keeps its
|
|
233
268
|
corner centre inside the arc only while its radius is under 0.5*sqrt(2)/(sqrt(2)-1), about 1.707.
|
|
269
|
+
|
|
270
|
+
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.
|
|
234
273
|
"""
|
|
235
274
|
n, q = len(modules), QUIET_ZONE
|
|
236
275
|
side = n + 2 * q
|
|
@@ -251,17 +290,17 @@ def svg(modules, labelledby=None):
|
|
|
251
290
|
label = f' aria-labelledby="{labelledby}"' if labelledby else ""
|
|
252
291
|
out = [f'<svg class="qr" viewBox="0 0 {side} {side}" role="img"{label}>',
|
|
253
292
|
f'<rect width="{side}" height="{side}" rx="1.5" fill="{_QR_TILE}"/>',
|
|
254
|
-
f'<g fill="{_QR_INK}">{dots}</g>']
|
|
293
|
+
f'<g class="modules"><g fill="{_QR_INK}">{dots}</g>']
|
|
255
294
|
for top, left in finders:
|
|
256
|
-
|
|
257
|
-
out.append(f'<rect x="{x}" y="{y}" width="7" height="7" rx="1.5" fill="{_QR_RING}"/>'
|
|
258
|
-
f'<rect x="{x + 1}" y="{y + 1}" width="5" height="5" rx="1.4" fill="{_QR_TILE}"/>'
|
|
259
|
-
f'<rect x="{x + 2}" y="{y + 2}" width="3" height="3" rx="1" fill="{_QR_INK}"/>')
|
|
295
|
+
out.append(_finder(left + q, top + q))
|
|
260
296
|
for a, b in alignments:
|
|
261
297
|
x, y = b - 2 + q, a - 2 + q
|
|
262
298
|
out.append(f'<rect x="{x}" y="{y}" width="5" height="5" rx="1.5" fill="{_QR_INK}"/>'
|
|
263
299
|
f'<rect x="{x + 1}" y="{y + 1}" width="3" height="3" rx=".9" fill="{_QR_TILE}"/>'
|
|
264
300
|
f'<rect x="{x + 2}" y="{y + 2}" width="1" height="1" rx=".3" fill="{_QR_INK}"/>')
|
|
301
|
+
out.append("</g>")
|
|
302
|
+
if mask is not None:
|
|
303
|
+
out.append(_flat_mask(n, q, mask))
|
|
265
304
|
out.append("</svg>")
|
|
266
305
|
return "".join(out)
|
|
267
306
|
|
|
@@ -362,7 +401,7 @@ def _runs(cells):
|
|
|
362
401
|
return out
|
|
363
402
|
|
|
364
403
|
|
|
365
|
-
def scene_svg(modules, plate_data_uri, labelledby=None, uid="q"):
|
|
404
|
+
def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
|
|
366
405
|
"""The pairing QR as the scene: the plate image under a group of polygons. None when the symbol is not version 3,
|
|
367
406
|
and the caller draws svg() instead.
|
|
368
407
|
|
|
@@ -371,7 +410,11 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q"):
|
|
|
371
410
|
light modules that have a dark module in front, then the light modules' tops (the plate lifted by the relief,
|
|
372
411
|
clipped to the light modules) and their ground dots. A light module's floor is always covered by its top,
|
|
373
412
|
its faces or its near neighbours' tops, so only the dark floors stay darkened and dotted, as in the renderer. The
|
|
374
|
-
near kerb clips it all.
|
|
413
|
+
near kerb clips it all.
|
|
414
|
+
|
|
415
|
+
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."""
|
|
375
418
|
n = len(modules)
|
|
376
419
|
if n != MODULES:
|
|
377
420
|
return None
|
|
@@ -415,6 +458,14 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q"):
|
|
|
415
458
|
faces = "".join(f'<path fill="{fill}" d="{"".join(d)}"/>' for fill, d in
|
|
416
459
|
((FACE_V_LIP, lips_v), (FACE_V, face_v), (FACE_U_LIP, lips_u), (FACE_U, face_u)) if d)
|
|
417
460
|
|
|
461
|
+
veil = ""
|
|
462
|
+
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>")
|
|
418
469
|
dot = _poly([((du - dv) * AX, (du + dv) * AY) for du, dv in _OCTAGON]) # one dot, centred on 0 0
|
|
419
470
|
dots = {True: [], False: []}
|
|
420
471
|
for r in range(n):
|
|
@@ -426,8 +477,8 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q"):
|
|
|
426
477
|
f'<defs><image id="{uid}p" width="{VIEW}" height="{VIEW}" href="{plate_data_uri}"/>'
|
|
427
478
|
f'<clipPath id="{uid}t"><path d="{"".join(tops)}"/></clipPath>'
|
|
428
479
|
f'<clipPath id="{uid}k"><path d="{kerb}"/></clipPath><path id="{uid}d" d="{dot}"/></defs>'
|
|
429
|
-
f'<use href="#{uid}p"/><g clip-path="url(#{uid}k)">'
|
|
480
|
+
f'<use href="#{uid}p"/><g class="modules" clip-path="url(#{uid}k)">'
|
|
430
481
|
f'<g fill="{INK}">{"".join(dots[True])}</g>'
|
|
431
482
|
f'<path fill-opacity="{1 - BED_DARK:.2f}" d="{area(0, 0, n, n, 0.0)}"/>{faces}'
|
|
432
483
|
f'<g clip-path="url(#{uid}t)"><use href="#{uid}p" y="{-drop:.2f}"/></g>'
|
|
433
|
-
f'<g fill="{GROUND}">{"".join(dots[False])}</g></g
|
|
484
|
+
f'<g fill="{GROUND}">{"".join(dots[False])}</g></g>{veil}</svg>')
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""The icon by the clock (lane pftray): an icon in the notification area whose menu opens the page, pauses or
|
|
2
|
-
resumes the alerts
|
|
2
|
+
resumes the alerts, quits the program and, once an update is ready, restarts into it, so the program can be stopped
|
|
3
|
+
with the page and the browser closed.
|
|
3
4
|
|
|
4
5
|
Standard library only: user32, shell32 and kernel32 by ctypes, bound for 64-bit Windows. The icon lives on a thread
|
|
5
6
|
of its own, a daemon, with a hidden top-level tool window (never shown, so no taskbar entry and no Alt+Tab slot,
|
|
@@ -29,7 +30,7 @@ NIF_MESSAGE, NIF_ICON, NIF_TIP = 0x1, 0x2, 0x4
|
|
|
29
30
|
IMAGE_ICON, LR_LOADFROMFILE, LR_DEFAULTSIZE = 1, 0x10, 0x40
|
|
30
31
|
MF_STRING, MF_SEPARATOR = 0x0, 0x800
|
|
31
32
|
TPM_RIGHTBUTTON, TPM_NONOTIFY, TPM_RETURNCMD = 0x2, 0x80, 0x100
|
|
32
|
-
OPEN, PAUSE, RESUME, QUIT = 1, 2, 3, 4 # the menu's commands
|
|
33
|
+
OPEN, PAUSE, RESUME, QUIT, RESTART = 1, 2, 3, 4, 5 # the menu's commands
|
|
33
34
|
|
|
34
35
|
FAILED_LINE = " [tray] {call} failed (error {code})"
|
|
35
36
|
BROKE_LINE = " [tray] the icon broke: {kind}"
|
|
@@ -122,10 +123,13 @@ def _say(line):
|
|
|
122
123
|
class Tray:
|
|
123
124
|
"""The icon by the clock. `paused` answers the watcher's state and `words` the page's table for the language set
|
|
124
125
|
at that moment; both are read at each click. `win32` is the facade of the Win32 functions, built from ctypes on
|
|
125
|
-
Windows when None; a test passes a fake.
|
|
126
|
+
Windows when None; a test passes a fake. `update_ready` answers the version an install made ready, or None, read
|
|
127
|
+
at each click as well, and `on_restart` is the page's restart: with both, a ready version adds its item."""
|
|
126
128
|
|
|
127
|
-
def __init__(self, url, on_open, on_pause, on_resume, on_quit, paused, words, icon_path, win32=None
|
|
129
|
+
def __init__(self, url, on_open, on_pause, on_resume, on_quit, paused, words, icon_path, win32=None,
|
|
130
|
+
update_ready=None, on_restart=None):
|
|
128
131
|
self.url = url
|
|
132
|
+
self._update_ready, self._on_restart = update_ready, on_restart
|
|
129
133
|
self._on_open, self._on_pause, self._on_resume, self._on_quit = on_open, on_pause, on_resume, on_quit
|
|
130
134
|
self._paused, self._words, self._icon_path = paused, words, str(icon_path)
|
|
131
135
|
self._win32 = win32
|
|
@@ -277,12 +281,14 @@ class Tray:
|
|
|
277
281
|
answers 0 when the menu is dismissed, which is no failure."""
|
|
278
282
|
win32, words = self._win32, self._words()
|
|
279
283
|
middle = (RESUME, words["resume"]) if self._paused() else (PAUSE, words["pause"])
|
|
284
|
+
ready = self._update_ready() if self._update_ready is not None and self._on_restart is not None else None
|
|
285
|
+
restart = () if ready is None else ((RESTART, words["update_tray_restart"].format(version=ready)),)
|
|
280
286
|
menu = win32.CreatePopupMenu()
|
|
281
287
|
if not menu:
|
|
282
288
|
self._failed("CreatePopupMenu")
|
|
283
289
|
return
|
|
284
290
|
try:
|
|
285
|
-
for item in ((OPEN, words["tray_open"]), middle, None, (QUIT, words["quit"])):
|
|
291
|
+
for item in ((OPEN, words["tray_open"]), middle, None, *restart, (QUIT, words["quit"])):
|
|
286
292
|
flags, command, text = (MF_SEPARATOR, 0, None) if item is None else (MF_STRING, *item)
|
|
287
293
|
if not win32.AppendMenuW(menu, flags, command, text):
|
|
288
294
|
self._failed("AppendMenuW")
|
|
@@ -304,7 +310,7 @@ class Tray:
|
|
|
304
310
|
self._on_pause()
|
|
305
311
|
elif chosen == RESUME:
|
|
306
312
|
self._on_resume()
|
|
307
|
-
elif chosen
|
|
308
|
-
self._on_quit()
|
|
313
|
+
elif chosen in (QUIT, RESTART): # either ends this copy: its icon goes at once
|
|
314
|
+
(self._on_quit if chosen == QUIT else self._on_restart)()
|
|
309
315
|
if not win32.PostMessageW(hwnd, WM_CLOSE, 0, 0):
|
|
310
316
|
self._failed("PostMessageW")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The update (OR-103): one GET to PyPI a minute after the start and every six hours after, and the newer version
|
|
2
|
+
installed with pip.
|
|
3
|
+
|
|
4
|
+
`auto` checks and installs in the background, and the page then offers «Reiniciar ahora»; `notify` checks only, and
|
|
5
|
+
the page offers «Actualizar», which installs and then restarts; `off` never checks. The check carries nothing of the
|
|
6
|
+
pair: one GET to pypi.org with the program's User-Agent. pip runs with python.exe beside this interpreter, since the
|
|
7
|
+
output of pythonw goes nowhere, with the arguments of the paste (install.ps1:184) and the version pinned, and never
|
|
8
|
+
with a console over a game; its output goes to update-pip.log in the config folder, never to pipes of this copy, and
|
|
9
|
+
main's stop waits for it a minute before the run file goes. The running copy is never touched: every module was
|
|
10
|
+
imported at its start, so the new
|
|
11
|
+
files take effect at the next start, the one «Reiniciar ahora» asks for.
|
|
12
|
+
"""
|
|
13
|
+
import importlib.metadata
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import urllib.request
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import config, worker
|
|
24
|
+
|
|
25
|
+
DISTRIBUTION = "pendify"
|
|
26
|
+
PYPI_URL = f"https://pypi.org/pypi/{DISTRIBUTION}/json"
|
|
27
|
+
MODES = config.UPDATE_MODES
|
|
28
|
+
AUTO, NOTIFY, OFF = MODES
|
|
29
|
+
FIRST_ROUND_SECONDS = 60
|
|
30
|
+
ROUND_SECONDS = 6 * 60 * 60
|
|
31
|
+
INSTALL_SECONDS = 10 * 60
|
|
32
|
+
# How long main's stop waits for an install under way before the run file goes.
|
|
33
|
+
INSTALL_JOIN_SECONDS = 60
|
|
34
|
+
# pip's output, in the config folder: a file, never a pipe of this copy, which a quit or a logoff would close under a
|
|
35
|
+
# pip that is swapping the files, and pip's next line would then raise before its rollback.
|
|
36
|
+
PIP_LOG_NAME = "update-pip.log"
|
|
37
|
+
# What the page reads: the state, with the version it is about.
|
|
38
|
+
NONE, AVAILABLE, INSTALLING, READY, FAILED = "none", "available", "installing", "ready", "failed"
|
|
39
|
+
# No console over a game: the flags of the process pip runs in.
|
|
40
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
41
|
+
DETACHED_PROCESS = 0x00000008
|
|
42
|
+
_MAX_ANSWER_BYTES = 4 * 1024 * 1024
|
|
43
|
+
_VERSION = re.compile(r"([0-9]+)\.([0-9]+)\.([0-9]+)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parts(version):
|
|
47
|
+
"""X.Y.Z as a tuple of ints; None for anything else."""
|
|
48
|
+
match = _VERSION.fullmatch(version) if isinstance(version, str) else None
|
|
49
|
+
return tuple(int(part) for part in match.groups()) if match else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def newer(remote, local):
|
|
53
|
+
"""True only when both are X.Y.Z and `remote` is the higher; anything else, on either side, is not newer."""
|
|
54
|
+
remote_parts, local_parts = _parts(remote), _parts(local)
|
|
55
|
+
return remote_parts is not None and local_parts is not None and remote_parts > local_parts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def installed_version():
|
|
59
|
+
"""The version the disk holds now, read fresh from the package's metadata; None when it cannot be read."""
|
|
60
|
+
try:
|
|
61
|
+
version = importlib.metadata.version(DISTRIBUTION)
|
|
62
|
+
except Exception: # not installed (a checkout) or a half-written dist-info: no version
|
|
63
|
+
return None
|
|
64
|
+
return version.strip() if isinstance(version, str) and version.strip() else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# The version this copy runs: read once at import, at the start, before any install can change the disk.
|
|
68
|
+
RUNNING_VERSION = installed_version()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check(urlopen=urllib.request.urlopen):
|
|
72
|
+
"""PyPI's latest version of the package, as its JSON names it in info.version; raises on any failure."""
|
|
73
|
+
request = urllib.request.Request(PYPI_URL, headers={"User-Agent": worker.USER_AGENT})
|
|
74
|
+
with urlopen(request, timeout=worker.TIMEOUT_SECONDS) as answer:
|
|
75
|
+
data = json.loads(answer.read(_MAX_ANSWER_BYTES).decode("utf-8"))
|
|
76
|
+
return data["info"]["version"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pip_command(version, executable, in_venv):
|
|
80
|
+
"""The paste's pip arguments (install.ps1:184) with the version pinned, run by python.exe beside `executable`;
|
|
81
|
+
without --user in a venv, where pip refuses it."""
|
|
82
|
+
user = [] if in_venv else ["--user"]
|
|
83
|
+
return [str(Path(executable).with_name("python.exe")), "-m", "pip", "install", *user, "--upgrade",
|
|
84
|
+
"--force-reinstall", "--no-deps", "--no-warn-script-location", f"{DISTRIBUTION}=={version}"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def install(version, log_path, run=subprocess.run, installed=installed_version, executable=None, in_venv=None):
|
|
88
|
+
"""`version` onto the disk: (True, None) when the disk already holds it, which skips pip, or when pip ends 0;
|
|
89
|
+
(False, line) otherwise, the line pip's last one, read from `log_path` once pip has ended, or the name of what
|
|
90
|
+
kept pip from running. pip's stdout and stderr go to `log_path`, truncated at each install, never to pipes of
|
|
91
|
+
this process: a copy that stops while pip swaps the files leaves pip writing to a file."""
|
|
92
|
+
if installed() == version:
|
|
93
|
+
return True, None
|
|
94
|
+
executable = sys.executable if executable is None else executable
|
|
95
|
+
in_venv = sys.prefix != sys.base_prefix if in_venv is None else in_venv
|
|
96
|
+
# pip's own version notice would be its last line; the variable keeps it out, the arguments stay the paste's.
|
|
97
|
+
# Unbuffered, pip's stdout and stderr reach the one file in the order pip wrote them, so its last line is last.
|
|
98
|
+
environment = {**os.environ, "PIP_DISABLE_PIP_VERSION_CHECK": "1", "PYTHONUNBUFFERED": "1"}
|
|
99
|
+
try:
|
|
100
|
+
with open(log_path, "wb") as output:
|
|
101
|
+
done = run(pip_command(version, executable, in_venv), stdin=subprocess.DEVNULL, stdout=output,
|
|
102
|
+
stderr=output, timeout=INSTALL_SECONDS, creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS,
|
|
103
|
+
env=environment)
|
|
104
|
+
if done.returncode == 0:
|
|
105
|
+
return True, None
|
|
106
|
+
written = Path(log_path).read_bytes().decode("utf-8", "replace")
|
|
107
|
+
except (OSError, subprocess.SubprocessError) as failure:
|
|
108
|
+
return False, type(failure).__name__
|
|
109
|
+
lines = [line.strip() for line in written.splitlines() if line.strip()]
|
|
110
|
+
return False, lines[-1] if lines else f"pip ended {done.returncode}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _log(line):
|
|
114
|
+
print(f" [update] {line}", file=sys.stderr, flush=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Updater:
|
|
118
|
+
"""The rounds on their own daemon thread, never the watcher's nor the tick loop's, and the state the page reads.
|
|
119
|
+
|
|
120
|
+
`check` answers PyPI's latest version, `install(version)` answers (done, line), pip itself with its output in
|
|
121
|
+
update-pip.log of `folder` (the config folder) when None, `restart` stops this copy once the restart flag is set
|
|
122
|
+
(main's quit), `clock(seconds)` waits before a round and answers True once the updater is closed (the close's
|
|
123
|
+
own wait when None), and `log` takes a failure's one line (stderr when None)."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, current_version, mode, check=check, install=None, restart=None, clock=None, log=None,
|
|
126
|
+
folder=None):
|
|
127
|
+
if mode not in MODES:
|
|
128
|
+
raise ValueError("the update mode is not auto, notify or off")
|
|
129
|
+
self.pip_log = None if folder is None else Path(folder) / PIP_LOG_NAME
|
|
130
|
+
if install is None and self.pip_log is None:
|
|
131
|
+
raise ValueError("pip's output needs the config folder: no update without it")
|
|
132
|
+
self.current_version, self.mode = current_version, mode
|
|
133
|
+
self._check, self._install, self._restart = check, self._pip if install is None else install, restart
|
|
134
|
+
self._closed = threading.Event()
|
|
135
|
+
self._wait = self._closed.wait if clock is None else clock
|
|
136
|
+
self._log = _log if log is None else log
|
|
137
|
+
self._lock = threading.Lock()
|
|
138
|
+
self._state = (NONE, None, None)
|
|
139
|
+
# Set while no install runs: what wait() reads; cleared under the lock wherever the state turns installing.
|
|
140
|
+
self._idle = threading.Event()
|
|
141
|
+
self._idle.set()
|
|
142
|
+
# The flag main reads after the run file's release: set, it starts a new copy.
|
|
143
|
+
self.restart_requested = threading.Event()
|
|
144
|
+
# Whether that copy opens its page, so it starts without --quiet: every restart asked today does.
|
|
145
|
+
self.restart_opens_page = True
|
|
146
|
+
self.thread = None
|
|
147
|
+
|
|
148
|
+
def snapshot(self):
|
|
149
|
+
"""What the page reads: the state, the version it is about and, for a failed install, pip's last line."""
|
|
150
|
+
with self._lock:
|
|
151
|
+
state, version, error = self._state
|
|
152
|
+
return {"state": state, "version": version, "error": error}
|
|
153
|
+
|
|
154
|
+
def start(self):
|
|
155
|
+
"""The rounds on their own daemon thread; none in off mode."""
|
|
156
|
+
if self.mode == OFF or self.thread is not None:
|
|
157
|
+
return
|
|
158
|
+
self.thread = threading.Thread(target=self.run, name="update", daemon=True)
|
|
159
|
+
self.thread.start()
|
|
160
|
+
|
|
161
|
+
def close(self):
|
|
162
|
+
"""Ends the wait between rounds; an install under way runs on in its own process, and wait() is how main's
|
|
163
|
+
stop waits for it."""
|
|
164
|
+
self._closed.set()
|
|
165
|
+
|
|
166
|
+
def wait(self, timeout=INSTALL_JOIN_SECONDS):
|
|
167
|
+
"""Main's stop, before the run file's release: an install under way waited for, up to `timeout` seconds, so
|
|
168
|
+
a quit does not end this copy while pip swaps the files. True once none runs; False, said in one log line,
|
|
169
|
+
when pip still runs at the bound and goes on alone, its output in its file."""
|
|
170
|
+
if self._idle.wait(timeout):
|
|
171
|
+
return True
|
|
172
|
+
with self._lock:
|
|
173
|
+
version = self._state[1]
|
|
174
|
+
self._log(f"the install of {version} did not end in {timeout:g} seconds; pip goes on alone")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def run(self):
|
|
178
|
+
"""One round a minute after the start, then one every six hours, until the close."""
|
|
179
|
+
delay = FIRST_ROUND_SECONDS
|
|
180
|
+
while not self._wait(delay):
|
|
181
|
+
self.round()
|
|
182
|
+
delay = ROUND_SECONDS
|
|
183
|
+
|
|
184
|
+
def round(self):
|
|
185
|
+
"""One check; a newer version reads available, and auto mode installs it at once. A failure is one log
|
|
186
|
+
line, and the next round runs. Nothing is checked again once an install has started, ended ready or failed:
|
|
187
|
+
a failed version is tried again at the next start only."""
|
|
188
|
+
if self.mode == OFF or not self._open():
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
latest = self._check()
|
|
192
|
+
except Exception as failure: # the network, PyPI or its answer: silent on the page until the next round
|
|
193
|
+
self._log(f"the check failed: {type(failure).__name__}")
|
|
194
|
+
return
|
|
195
|
+
if not newer(latest, self.current_version):
|
|
196
|
+
return
|
|
197
|
+
with self._lock:
|
|
198
|
+
if self._state[0] not in (NONE, AVAILABLE):
|
|
199
|
+
return
|
|
200
|
+
self._state = (AVAILABLE, latest, None)
|
|
201
|
+
if self.mode == AUTO:
|
|
202
|
+
self._apply(latest)
|
|
203
|
+
|
|
204
|
+
def request_install(self):
|
|
205
|
+
"""The page's «Actualizar» in notify mode: the version seen installed on its own thread, then the restart.
|
|
206
|
+
False, and nothing done, in any other mode or state."""
|
|
207
|
+
with self._lock:
|
|
208
|
+
state, version, _ = self._state
|
|
209
|
+
if self.mode != NOTIFY or state != AVAILABLE:
|
|
210
|
+
return False
|
|
211
|
+
self._state = (INSTALLING, version, None)
|
|
212
|
+
self._idle.clear()
|
|
213
|
+
threading.Thread(target=self._install_then_restart, args=(version,), name="update-install",
|
|
214
|
+
daemon=True).start()
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
def restart(self, opens_page=True):
|
|
218
|
+
"""A request only: the flag main reads after the run file's release, then the stop of this copy. With
|
|
219
|
+
`opens_page`, true for every restart asked today (the page's «Reiniciar ahora», the restart after
|
|
220
|
+
«Actualizar», the icon's item), the new copy starts without --quiet, so its page opens; a restart that does not
|
|
221
|
+
ask for it keeps the run's own --quiet."""
|
|
222
|
+
self.restart_opens_page = opens_page
|
|
223
|
+
self.restart_requested.set()
|
|
224
|
+
if self._restart is not None:
|
|
225
|
+
self._restart()
|
|
226
|
+
|
|
227
|
+
def _open(self):
|
|
228
|
+
with self._lock:
|
|
229
|
+
return self._state[0] in (NONE, AVAILABLE)
|
|
230
|
+
|
|
231
|
+
def _pip(self, version):
|
|
232
|
+
"""pip itself, its output in update-pip.log of the config folder."""
|
|
233
|
+
return install(version, self.pip_log)
|
|
234
|
+
|
|
235
|
+
def _install_then_restart(self, version):
|
|
236
|
+
if self._apply(version) and not self._closed.is_set():
|
|
237
|
+
self.restart()
|
|
238
|
+
|
|
239
|
+
def _apply(self, version):
|
|
240
|
+
with self._lock:
|
|
241
|
+
self._state = (INSTALLING, version, None)
|
|
242
|
+
self._idle.clear()
|
|
243
|
+
try:
|
|
244
|
+
done, line = self._install(version)
|
|
245
|
+
except Exception as failure: # never a dead thread with the page reading installing for ever
|
|
246
|
+
done, line = False, type(failure).__name__
|
|
247
|
+
with self._lock:
|
|
248
|
+
self._state = (READY, version, None) if done else (FAILED, version, line)
|
|
249
|
+
self._idle.set()
|
|
250
|
+
if not done:
|
|
251
|
+
self._log(f"the install of {version} failed")
|
|
252
|
+
return done
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pendify
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
@@ -49,11 +49,13 @@ Si Windows responde que no encuentra `python`, usa `py` en su lugar: `py -m pip
|
|
|
49
49
|
|
|
50
50
|
Así no se crean los accesos directos en el Escritorio ni en el menú Inicio, y el inicio con Windows queda en el interruptor de la página. Para actualizar, pulsa «Salir» en la página, ejecuta otra vez la misma línea de pip y vuelve a iniciarlo. Para quitarlo, apaga primero el interruptor «Iniciar con Windows» en la página, pulsa «Salir» y después ejecuta `python -m pip uninstall pendify`. La configuración en `%APPDATA%\pendify`, con el enlace con el teléfono, se queda; puedes borrar esa carpeta a mano.
|
|
51
51
|
|
|
52
|
+
El programa revisa PyPI cada seis horas y, cuando hay una versión más nueva, la instala en segundo plano. La página ofrece entonces «Reiniciar ahora» para empezar a usarla. Para apagarlo, inícialo con `--no-update` o escribe `"update": "off"` en `%APPDATA%\pendify\config.json`. Las actualizaciones automáticas se instalan para tu usuario (--user).
|
|
53
|
+
|
|
52
54
|
### Enlazar
|
|
53
55
|
|
|
54
56
|
Al iniciarse, el navegador abre una página local con un código QR. Escanéalo con la cámara del teléfono: se abre la app Pendi y te pide confirmar el enlace. La página muestra cuando el enlace quedó hecho.
|
|
55
57
|
|
|
56
|
-
El código QR y la clave se muestran durante un minuto al pulsar «Mostrar el código
|
|
58
|
+
El código QR y la clave están ocultos; se muestran durante un minuto al pulsar «Mostrar el código», y la página dice para qué sirve el código.
|
|
57
59
|
|
|
58
60
|
El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el programa guarda la elección para la próxima vez.
|
|
59
61
|
|
|
@@ -112,11 +114,13 @@ If Windows answers that `python` was not found, use `py` in its place: `py -m pi
|
|
|
112
114
|
|
|
113
115
|
This way leaves no shortcut on the Desktop or in the Start menu, and the start with Windows is the switch on the page. To update, press «Quit» on the page, run the same pip line again and start it again. To remove it, first turn off the «Start with Windows» switch on the page, press «Quit», then run `python -m pip uninstall pendify`. The configuration in `%APPDATA%\pendify`, with the link to the phone, stays; you can delete that folder by hand.
|
|
114
116
|
|
|
117
|
+
The program checks PyPI every six hours and, when a newer version exists, installs it in the background. The page then offers «Restart now» to start using it. To turn this off, start it with `--no-update` or write `"update": "off"` in `%APPDATA%\pendify\config.json`. Automatic updates install for your user (--user).
|
|
118
|
+
|
|
115
119
|
### Link
|
|
116
120
|
|
|
117
121
|
When it starts, the browser opens a local page with a QR code. Scan it with the phone's camera: the Pendi app opens and asks you to confirm the link. The page shows when the link is done.
|
|
118
122
|
|
|
119
|
-
The QR code and the key show for a minute when «Show the code» is pressed, and
|
|
123
|
+
The QR code and the key are hidden; they show for a minute when «Show the code» is pressed, and the page says what the code is for.
|
|
120
124
|
|
|
121
125
|
The button at the top right switches between the light and the dark theme, and the program keeps the choice for the next time.
|
|
122
126
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|