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.
Files changed (28) hide show
  1. {pendify-0.1.4/src/pendify.egg-info → pendify-0.1.5}/PKG-INFO +7 -3
  2. {pendify-0.1.4 → pendify-0.1.5}/README.md +6 -2
  3. {pendify-0.1.4 → pendify-0.1.5}/pyproject.toml +1 -1
  4. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/__main__.py +59 -7
  5. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/config.py +18 -6
  6. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/page.py +130 -25
  7. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/qr.py +61 -10
  8. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/tray.py +13 -7
  9. pendify-0.1.5/src/pendify/update.py +252 -0
  10. {pendify-0.1.4 → pendify-0.1.5/src/pendify.egg-info}/PKG-INFO +7 -3
  11. {pendify-0.1.4 → pendify-0.1.5}/src/pendify.egg-info/SOURCES.txt +1 -0
  12. {pendify-0.1.4 → pendify-0.1.5}/LICENSE +0 -0
  13. {pendify-0.1.4 → pendify-0.1.5}/MANIFEST.in +0 -0
  14. {pendify-0.1.4 → pendify-0.1.5}/setup.cfg +0 -0
  15. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/__init__.py +0 -0
  16. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/alert.py +0 -0
  17. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/autostart.py +0 -0
  18. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/client.py +0 -0
  19. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/codes.py +0 -0
  20. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/icon.py +0 -0
  21. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/pairing.py +0 -0
  22. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/pendify.ico +0 -0
  23. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/plate_almena.py +0 -0
  24. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/runfile.py +0 -0
  25. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/watcher.py +0 -0
  26. {pendify-0.1.4 → pendify-0.1.5}/src/pendify/worker.py +0 -0
  27. {pendify-0.1.4 → pendify-0.1.5}/src/pendify.egg-info/dependency_links.txt +0 -0
  28. {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.4
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» y luego se ocultan de nuevo.
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 then hide again.
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» y luego se ocultan de nuevo.
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 then hide again.
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.4"
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 _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray):
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
- if theme is _KEEP or lang is _KEEP:
182
- stored = self._stored()
183
- theme = _choice(stored, "theme", THEMES) if theme is _KEEP else theme
184
- lang = _choice(stored, "lang", LANGS) if lang is _KEEP else lang
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:0 0 16px;padding:20px;border-radius:16px;"
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 two paragraphs keep theirs (.intro 46ch, .scan 40ch).
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 shown; the key sits under the mask until «Mostrar el código» (the script sets data-shown).
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; says so when the program is gone.
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 lang=document.documentElement.lang;"
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
- "setInterval(()=>fetch('/state').then(r=>r.json()).then(j=>{s.textContent=j.text;s.dataset.shown=shown;"
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||(sw&&j.autostart!==undefined&&String(j.autostart)!==String(sw.defaultChecked)))"
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.closed;s.dataset.shown='closed';}),5000);"
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 shown, the mask
623
- # and the reveal gone. The code's rule repeats the mask's own selector and comes later, so it wins.
624
- _NOSCRIPT_STYLE = '.key[data-shown="false"] .code{visibility:visible}.mask,.reveal{display:none}'
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} data-closed="{words["state_closed"]}">'
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="scan")
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="scan")
880
- link += [f'<p class="scan" id="scan">{words["scan"]}</p>',
881
- # The code is shown (owner 2026-10-01); the key's text sits under the mask until its person
882
- # presses the reveal, a real button whose label says what a press does; _SCRIPT masks the key
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><button type="button" class="reveal" '
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
- def svg(modules, labelledby=None):
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
- x, y = left + q, top + q
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></svg>')
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 and quits the program, so the program can be stopped with the page and the browser closed.
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 == QUIT:
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.4
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» y luego se ocultan de nuevo.
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 then hide again.
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
 
@@ -17,6 +17,7 @@ src/pendify/plate_almena.py
17
17
  src/pendify/qr.py
18
18
  src/pendify/runfile.py
19
19
  src/pendify/tray.py
20
+ src/pendify/update.py
20
21
  src/pendify/watcher.py
21
22
  src/pendify/worker.py
22
23
  src/pendify.egg-info/PKG-INFO
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