pendify 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {pendify-0.1.4/src/pendify.egg-info → pendify-0.1.6}/PKG-INFO +9 -5
  2. {pendify-0.1.4 → pendify-0.1.6}/README.md +8 -4
  3. {pendify-0.1.4 → pendify-0.1.6}/pyproject.toml +1 -1
  4. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/__main__.py +147 -7
  5. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/config.py +18 -6
  6. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/page.py +179 -25
  7. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/qr.py +153 -10
  8. pendify-0.1.6/src/pendify/runfile.py +501 -0
  9. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/tray.py +13 -7
  10. pendify-0.1.6/src/pendify/update.py +252 -0
  11. {pendify-0.1.4 → pendify-0.1.6/src/pendify.egg-info}/PKG-INFO +9 -5
  12. {pendify-0.1.4 → pendify-0.1.6}/src/pendify.egg-info/SOURCES.txt +1 -0
  13. pendify-0.1.4/src/pendify/runfile.py +0 -246
  14. {pendify-0.1.4 → pendify-0.1.6}/LICENSE +0 -0
  15. {pendify-0.1.4 → pendify-0.1.6}/MANIFEST.in +0 -0
  16. {pendify-0.1.4 → pendify-0.1.6}/setup.cfg +0 -0
  17. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/__init__.py +0 -0
  18. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/alert.py +0 -0
  19. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/autostart.py +0 -0
  20. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/client.py +0 -0
  21. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/codes.py +0 -0
  22. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/icon.py +0 -0
  23. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/pairing.py +0 -0
  24. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/pendify.ico +0 -0
  25. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/plate_almena.py +0 -0
  26. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/watcher.py +0 -0
  27. {pendify-0.1.4 → pendify-0.1.6}/src/pendify/worker.py +0 -0
  28. {pendify-0.1.4 → pendify-0.1.6}/src/pendify.egg-info/dependency_links.txt +0 -0
  29. {pendify-0.1.4 → pendify-0.1.6}/src/pendify.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pendify
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/ElkinDev/PendiFy
@@ -28,7 +28,7 @@ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en Po
28
28
 
29
29
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
30
30
 
31
- La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
31
+ La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine. Si PendiFy ya está abierto, la línea instala la versión nueva y la copia nueva reemplaza a la anterior por sí sola.
32
32
 
33
33
  La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
34
34
 
@@ -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
 
@@ -91,7 +93,7 @@ Press Windows + R, paste this whole line and press Enter. It also works in Power
91
93
 
92
94
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
93
95
 
94
- The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
96
+ The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done. If PendiFy is already open, the line installs the new version and the new copy replaces the older one by itself.
95
97
 
96
98
  The window stays open at the end so you can read the result; close it when it is done.
97
99
 
@@ -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
 
@@ -12,7 +12,7 @@ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en Po
12
12
 
13
13
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
14
14
 
15
- La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
15
+ La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine. Si PendiFy ya está abierto, la línea instala la versión nueva y la copia nueva reemplaza a la anterior por sí sola.
16
16
 
17
17
  La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
18
18
 
@@ -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
 
@@ -75,7 +77,7 @@ Press Windows + R, paste this whole line and press Enter. It also works in Power
75
77
 
76
78
  powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
77
79
 
78
- The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
80
+ The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done. If PendiFy is already open, the line installs the new version and the new copy replaces the older one by itself.
79
81
 
80
82
  The window stays open at the end so you can read the result; close it when it is done.
81
83
 
@@ -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.6"
8
8
  description = "Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,26 +4,36 @@
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 start of a newer version over a running older copy replaces it: the older copy is asked to
14
+ stop through its page's /replace with the run file's secret, or ended by its pid when it has no secret or its page
15
+ does not answer, and this start then claims the run file as a first one does. A restart the page asks for
16
+ («Reiniciar ahora», once a newer version is installed, or the
17
+ icon's «Reiniciar para actualizar») starts a new copy once the run file is released: without --quiet, so its page
18
+ opens, and with the --dry, --data-dir, --worker and --client-lockfile of this one.
13
19
  Where no console is attached (a pythonw start), the line a start ends on is also shown in a
14
20
  message box. `--data-dir`, `--worker` and `--client-lockfile` are the
15
21
  test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
16
22
  """
17
23
  import argparse
24
+ import http.client
18
25
  import os
19
26
  import signal
27
+ import subprocess
20
28
  import sys
21
29
  import threading
22
30
  import time
31
+ import urllib.parse
32
+ import urllib.request
23
33
  import webbrowser
24
34
  from pathlib import Path
25
35
 
26
- from . import alert, client, config, page, pairing, runfile, watcher, worker
36
+ from . import alert, client, config, page, pairing, runfile, update, watcher, worker
27
37
  from .autostart import Autostart
28
38
  from .tray import Tray
29
39
 
@@ -41,8 +51,22 @@ PAGE_NOT_KNOWN_LINE = ("already running: the page of the program that runs is no
41
51
  "moment to open it")
42
52
  CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
43
53
  FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
54
+ RESTART_FAILED_LINE = "cannot restart: the new copy did not start; start the program again"
55
+ # The line a start that replaced an older copy shows, in the page's language: the older copy's version, «desconocida»
56
+ # or "unknown" for a record with none, then this one's.
57
+ REPLACED_LINES = {"es": "Se reemplazó la copia anterior ({old}) por esta ({new}).",
58
+ "en": "The older copy ({old}) was replaced by this one ({new})."}
59
+ UNKNOWN_VERSION = {"es": "desconocida", "en": "unknown"}
60
+ # The line a start prints when the older copy it would replace cannot be ended, in the page's language, with the
61
+ # system's reason.
62
+ CLOSE_REFUSED_LINES = {"es": "No se pudo cerrar la copia anterior: {reason}",
63
+ "en": "The older copy could not be closed: {reason}"}
64
+ REPLACE_TIMEOUT_SECONDS = 5.0 # the replace post to the older copy's page
44
65
  _MB_ICONINFORMATION = 0x40
45
66
  _MB_SETFOREGROUND = 0x10000
67
+ # The restart's new copy: no console, its own process group, out of this one's.
68
+ _DETACHED_PROCESS = 0x00000008
69
+ _CREATE_NEW_PROCESS_GROUP = 0x00000200
46
70
  # The icon by the clock's picture: pendify.ico beside the modules, the file install.ps1 points the shortcuts at.
47
71
  ICON_PATH = Path(__file__).with_name("pendify.ico")
48
72
 
@@ -82,6 +106,8 @@ def _arguments(argv):
82
106
  parser.add_argument("--dry", action="store_true", help="watch and alert, but never accept")
83
107
  parser.add_argument("--quiet", action="store_true",
84
108
  help="a start by the system: no browser, and no message box for a start that ends well")
109
+ parser.add_argument("--no-update", action="store_true",
110
+ help="no check of PyPI for a newer version in this run")
85
111
  commands = parser.add_subparsers(dest="command")
86
112
  ping = commands.add_parser("ping", parents=[common], help="send one alert with the stored pair")
87
113
  ping.add_argument("kind", choices=worker.KINDS)
@@ -154,14 +180,102 @@ def _ends(line, code, show):
154
180
  return code
155
181
 
156
182
 
157
- def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray):
183
+ def _restart(args, spawn, show, opens_page):
184
+ """The restart asked for, once the run file is free: a new copy of this program, detached and with no console,
185
+ which claims the run file as a first start does. One that opens its page, as every restart asked today does,
186
+ goes without --quiet; one that does not keeps this run's --quiet. This run's --dry, --data-dir, --worker and
187
+ --client-lockfile go with it either way."""
188
+ command = [sys.executable, "-m", __package__] + (["--quiet"] if args.quiet and not opens_page else [])
189
+ command += ["--dry"] if args.dry else []
190
+ for option in ("data_dir", "worker", "client_lockfile"):
191
+ if hasattr(args, option): # the parser's SUPPRESS: present only when this run was given it
192
+ command += ["--" + option.replace("_", "-"), str(getattr(args, option))]
193
+ try:
194
+ spawn(command, creationflags=_DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP, close_fds=True)
195
+ except OSError:
196
+ return _ends(RESTART_FAILED_LINE, 1, show)
197
+ return 0
198
+
199
+
200
+ def _replaces(holder):
201
+ """True when this copy is newer than `holder`, a live holder that is not closing: X.Y.Z compared as tuples of
202
+ ints, and a holder with no version, as an older copy writes its record, older than any. A copy whose own version
203
+ is not X.Y.Z replaces nothing."""
204
+ if update._parts(update.RUNNING_VERSION) is None:
205
+ return False
206
+ return holder["version"] is None or update.newer(update.RUNNING_VERSION, holder["version"])
207
+
208
+
209
+ def _ask_replace(holder):
210
+ """True when the holder's page answered 202 to POST /replace with the run file's secret; False for any other
211
+ answer, none, or no port."""
212
+ if holder["port"] is None:
213
+ return False
214
+ request = urllib.request.Request(f"http://{page.ADDRESS}:{holder['port']}/replace", method="POST",
215
+ data=urllib.parse.urlencode({"secret": holder["secret"]}).encode())
216
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # a loopback address never goes through one
217
+ try:
218
+ with opener.open(request, timeout=REPLACE_TIMEOUT_SECONDS) as answer:
219
+ return answer.status == 202
220
+ except (OSError, http.client.HTTPException): # a refusal (urllib.error.HTTPError), no answer in time, a broken one
221
+ return False
222
+
223
+
224
+ def _kept_language(store):
225
+ """The page's language before the page is made, with no browser to ask: the kept choice, else Spanish, as the
226
+ icon's words. A config file that cannot be read gives Spanish: the choice only words one line."""
227
+ try:
228
+ return store.read_lang() or page.language(None)
229
+ except (OSError, ValueError):
230
+ return page.language(None)
231
+
232
+
233
+ def _replace(run, holder, terminate, store):
234
+ """The older `holder` dealt with, then the run file claimed again: (the claim's answer, `holder` when this start
235
+ replaced it, else None). It is asked through its page's /replace; with no secret in its record or no 202 from its
236
+ page, `terminate` is asked to end it by its pid, which it does only for the process its record names
237
+ (runfile.terminate):
238
+ - a 202, or ended: once the run file no longer names it as a live process within CLOSING_WAIT_SECONDS, its file
239
+ gone or left stale by its death, the file is claimed; a holder still alive then is handed back as it was read;
240
+ - not-ours: the pid names another process now, so the file is stale: evicted while it still holds that record,
241
+ then claimed as a first start claims it;
242
+ - refused: the reason printed on one line and the file waited for as long, since the pid may be dying under
243
+ another start's terminate, then claimed again: a holder that still stands is read afresh, never the snapshot."""
244
+ if holder["secret"] is None or not _ask_replace(holder):
245
+ answer = terminate(holder["pid"], holder["started"], run.path)
246
+ if answer == runfile.NOT_OURS:
247
+ run.evict(holder)
248
+ return run.claim(), holder
249
+ if answer == runfile.REFUSED:
250
+ reason = answer.error.strerror or answer.error
251
+ print(CLOSE_REFUSED_LINES[_kept_language(store)].format(reason=reason), flush=True)
252
+ run.wait_released(holder, CLOSING_WAIT_SECONDS)
253
+ return run.claim(), None
254
+ if run.wait_released(holder, CLOSING_WAIT_SECONDS):
255
+ return run.claim(), holder
256
+ return holder, None
257
+
258
+
259
+ def _ready_version(updater):
260
+ """The version an install made ready, for the icon's item; None in any other state."""
261
+ seen = updater.snapshot()
262
+ return seen["version"] if seen["state"] == update.READY else None
263
+
264
+
265
+ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray, updater,
266
+ spawn, terminate):
158
267
  # A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
159
268
  calm = (lambda line: None) if args.quiet else show
160
269
  run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
270
+ replaced = None # the record of the older copy this start replaced
161
271
  try:
162
272
  holder = run.claim()
163
273
  if holder is not None and holder["closing"] and run.wait_released(holder, CLOSING_WAIT_SECONDS):
164
274
  holder = run.claim() # the closing copy let its file go: this start claims it as a first one does
275
+ # A running older copy is replaced (_replace); a holder that still stands after it keeps the road below,
276
+ # its page opened.
277
+ if holder is not None and not holder["closing"] and _replaces(holder):
278
+ holder, replaced = _replace(run, holder, terminate, store)
165
279
  except runfile.FolderNotWritable as refused:
166
280
  return _ends(FOLDER_FAILED_LINE.format(path=refused.filename), 1, show)
167
281
  except OSError:
@@ -179,7 +293,9 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
179
293
  opener(f"http://{page.ADDRESS}:{port}/")
180
294
  show(ALREADY_RUNNING_LINE)
181
295
  return 0
296
+ updating = None
182
297
  try:
298
+ mode = update.OFF if args.no_update else store.read_update()
183
299
  state = pairing.PairingState(store, lambda secret: worker.check(secret, base=base, timeout=timeout))
184
300
  watch, alerter = _watcher(args, store, state, base, timeout, stop, delay, beep)
185
301
 
@@ -195,16 +311,31 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
195
311
 
196
312
  pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
197
313
  on_resume=watch.resume, autostart=autostart, events=watch.events)
314
+ pairing_page.run = run # its /replace answers the secret the publish below writes
198
315
  url = pairing_page.start()
316
+ # The update's rounds on their own thread (update.py), built once the page serves; its restart is the
317
+ # page's quit, after the flag read below once the run file is free; pip's output goes to the config folder.
318
+ updating = updater(update.RUNNING_VERSION, mode, restart=quit_page, folder=store.path.parent)
319
+ pairing_page.updater = updating
199
320
  # The icon by the clock, under --quiet as well: the way to stop a copy the Run key started. Its words are
200
321
  # the page's, in the language the page resolves with no browser to ask: the kept choice, else Spanish.
201
322
  icon = tray(url, on_open=lambda: opener(url), on_pause=watch.pause, on_resume=watch.resume,
202
323
  on_quit=quit_page, paused=lambda: watch.paused,
203
- words=lambda: page.WORDS[pairing_page.language_of(None)], icon_path=ICON_PATH)
324
+ words=lambda: page.WORDS[pairing_page.language_of(None)], icon_path=ICON_PATH,
325
+ # «Reiniciar para actualizar a X» once an install is ready, the page's own restart
326
+ update_ready=lambda: _ready_version(updating), on_restart=updating.restart)
204
327
  watching = threading.Thread(target=watch.run, daemon=True)
205
328
  try:
206
329
  icon.start()
330
+ if mode != update.OFF:
331
+ updating.start()
207
332
  run.publish(pairing_page.port)
333
+ if replaced is not None: # in the page's language, as the icon's words; a box never holds the start
334
+ lang = pairing_page.language_of(None)
335
+ line = REPLACED_LINES[lang].format(old=replaced["version"] or UNKNOWN_VERSION[lang],
336
+ new=update.RUNNING_VERSION)
337
+ print(line, flush=True)
338
+ threading.Thread(target=calm, args=(line,), daemon=True).start()
208
339
  print(f"page: {url}", flush=True)
209
340
  if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
210
341
  print("open the address above in a browser", flush=True)
@@ -217,6 +348,7 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
217
348
  pass
218
349
  finally:
219
350
  stop.set()
351
+ updating.close() # the restart gate shuts at the quit
220
352
  # First, so a start made while this one stops waits for it instead of opening a closing page: Ctrl+C
221
353
  # and Ctrl+Break are marked only here, and the page's quit, marked already, is marked again.
222
354
  try:
@@ -228,8 +360,13 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
228
360
  alerter.flush(STOP_SECONDS)
229
361
  pairing_page.close()
230
362
  icon.close()
363
+ # An install under way is waited for before the run file goes, so a quit does not end this copy while
364
+ # pip swaps the files; past the bound pip goes on alone, its output in a file.
365
+ updating.wait(update.INSTALL_JOIN_SECONDS)
231
366
  finally:
232
367
  run.release()
368
+ if updating is not None and updating.restart_requested.is_set():
369
+ return _restart(args, spawn, show, updating.restart_opens_page)
233
370
  return 0
234
371
 
235
372
 
@@ -242,11 +379,14 @@ def _break_as_interrupt():
242
379
 
243
380
  def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
244
381
  beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
245
- sleep=time.sleep, tray=Tray):
382
+ sleep=time.sleep, tray=Tray, updater=update.Updater, spawn=subprocess.Popen, terminate=runfile.terminate):
246
383
  """`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
247
384
  (the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
248
385
  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."""
386
+ seam a test run fills with a fake, so no test shows a real icon. `updater` makes the update and `spawn` starts
387
+ the restart's new copy, the seams a test run fills with fakes, so no test reaches PyPI, runs pip or starts a
388
+ copy. `terminate` ends an older copy by its pid when it cannot be asked to stop, the seam every test run fakes, so
389
+ no test ends a process."""
250
390
  args = _arguments(sys.argv[1:] if argv is None else argv)
251
391
  store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
252
392
  base = getattr(args, "worker", worker.BASE_URL)
@@ -262,7 +402,7 @@ def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT
262
402
  try:
263
403
  return _serve(args, store, base, timeout, opener, stop or threading.Event(),
264
404
  delay or watcher.accept_delay, beep, show, clock, sleep,
265
- autostart if autostart is not None else Autostart(), tray)
405
+ autostart if autostart is not None else Autostart(), tray, updater, spawn, terminate)
266
406
  finally:
267
407
  if previous is not None:
268
408
  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)