pendify 0.1.2__tar.gz → 0.1.4__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 (27) hide show
  1. {pendify-0.1.2/src/pendify.egg-info → pendify-0.1.4}/PKG-INFO +3 -1
  2. {pendify-0.1.2 → pendify-0.1.4}/README.md +2 -0
  3. {pendify-0.1.2 → pendify-0.1.4}/pyproject.toml +1 -1
  4. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/__main__.py +19 -7
  5. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/page.py +16 -14
  6. pendify-0.1.4/src/pendify/tray.py +310 -0
  7. {pendify-0.1.2 → pendify-0.1.4/src/pendify.egg-info}/PKG-INFO +3 -1
  8. {pendify-0.1.2 → pendify-0.1.4}/src/pendify.egg-info/SOURCES.txt +1 -0
  9. {pendify-0.1.2 → pendify-0.1.4}/LICENSE +0 -0
  10. {pendify-0.1.2 → pendify-0.1.4}/MANIFEST.in +0 -0
  11. {pendify-0.1.2 → pendify-0.1.4}/setup.cfg +0 -0
  12. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/__init__.py +0 -0
  13. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/alert.py +0 -0
  14. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/autostart.py +0 -0
  15. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/client.py +0 -0
  16. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/codes.py +0 -0
  17. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/config.py +0 -0
  18. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/icon.py +0 -0
  19. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/pairing.py +0 -0
  20. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/pendify.ico +0 -0
  21. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/plate_almena.py +0 -0
  22. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/qr.py +0 -0
  23. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/runfile.py +0 -0
  24. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/watcher.py +0 -0
  25. {pendify-0.1.2 → pendify-0.1.4}/src/pendify/worker.py +0 -0
  26. {pendify-0.1.2 → pendify-0.1.4}/src/pendify.egg-info/dependency_links.txt +0 -0
  27. {pendify-0.1.2 → pendify-0.1.4}/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.2
3
+ Version: 0.1.4
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
@@ -61,6 +61,7 @@ El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el pr
61
61
 
62
62
  - Iniciar: el acceso directo `PendiFy`, o `pythonw -m pendify`, que es lo que ejecuta el acceso directo; sin consola, si ya está abierto o no puede iniciarse te lo dice en una ventana. Para verlo en una consola: `python -m pendify`.
63
63
  - Detener: pulsa «Salir» en la página del programa; si no la tienes abierta, iniciarlo otra vez la abre. Como último recurso, cierra el proceso `pythonw.exe` en el Administrador de tareas.
64
+ - Icono junto al reloj: un clic en el icono de PendiFy junto al reloj abre un menú para abrir la página, pausar o reanudar los avisos y salir del programa, también con la página y el navegador cerrados.
64
65
  - Pausar: «Pausar avisos», en la tarjeta «Este PC» de la página, deja de leer el cliente del juego: no acepta partidas ni avisa a tu teléfono, y la página sigue abierta. «Reanudar avisos» vuelve a leerlo. La pausa no se guarda: cada vez que el programa se inicia, empieza activo.
65
66
  - Iniciar con Windows: el interruptor «Iniciar con Windows», en la misma tarjeta, está apagado hasta que lo enciendas. Encendido, el programa empieza solo al iniciar sesión en Windows, solo para tu usuario y sin abrir el navegador ni la página.
66
67
  - Actividad: la tarjeta «Actividad» de la página lista lo que el programa hizo desde que empezó: el cliente del juego encontrado y perdido, cada fase, la partida aceptada y el aviso enviado. Guarda las últimas 50 líneas, solo en memoria.
@@ -123,6 +124,7 @@ The button at the top right switches between the light and the dark theme, and t
123
124
 
124
125
  - Start: the `PendiFy` shortcut, or `pythonw -m pendify`, which is what the shortcut runs; with no console, it tells you in a window when it is already running or cannot start. To see it in a console: `python -m pendify`.
125
126
  - Stop: press «Quit» on the program's page; if it is not open, starting the program again opens it. As a last resort, end the `pythonw.exe` process in Task Manager.
127
+ - Icon by the clock: a click on the PendiFy icon by the clock opens a menu that opens the page, pauses or resumes the alerts and quits the program, also with the page and the browser closed.
126
128
  - Pause: «Pause alerts», on the page's «This PC» card, stops reading the game client: it accepts no match and sends no alert to your phone, and the page stays open. «Resume alerts» reads it again. The pause is not kept: every start of the program is active again.
127
129
  - Start with Windows: the «Start with Windows» switch, on the same card, is off until you turn it on. When it is on, the program starts by itself when you sign in to Windows, for your user only, without opening the browser or the page.
128
130
  - Activity: the page's «Activity» card lists what the program did since it started: the game client found and lost, each phase, the match accepted and the alert sent. It keeps the last 50 lines, in memory only.
@@ -45,6 +45,7 @@ El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el pr
45
45
 
46
46
  - Iniciar: el acceso directo `PendiFy`, o `pythonw -m pendify`, que es lo que ejecuta el acceso directo; sin consola, si ya está abierto o no puede iniciarse te lo dice en una ventana. Para verlo en una consola: `python -m pendify`.
47
47
  - Detener: pulsa «Salir» en la página del programa; si no la tienes abierta, iniciarlo otra vez la abre. Como último recurso, cierra el proceso `pythonw.exe` en el Administrador de tareas.
48
+ - Icono junto al reloj: un clic en el icono de PendiFy junto al reloj abre un menú para abrir la página, pausar o reanudar los avisos y salir del programa, también con la página y el navegador cerrados.
48
49
  - Pausar: «Pausar avisos», en la tarjeta «Este PC» de la página, deja de leer el cliente del juego: no acepta partidas ni avisa a tu teléfono, y la página sigue abierta. «Reanudar avisos» vuelve a leerlo. La pausa no se guarda: cada vez que el programa se inicia, empieza activo.
49
50
  - Iniciar con Windows: el interruptor «Iniciar con Windows», en la misma tarjeta, está apagado hasta que lo enciendas. Encendido, el programa empieza solo al iniciar sesión en Windows, solo para tu usuario y sin abrir el navegador ni la página.
50
51
  - Actividad: la tarjeta «Actividad» de la página lista lo que el programa hizo desde que empezó: el cliente del juego encontrado y perdido, cada fase, la partida aceptada y el aviso enviado. Guarda las últimas 50 líneas, solo en memoria.
@@ -107,6 +108,7 @@ The button at the top right switches between the light and the dark theme, and t
107
108
 
108
109
  - Start: the `PendiFy` shortcut, or `pythonw -m pendify`, which is what the shortcut runs; with no console, it tells you in a window when it is already running or cannot start. To see it in a console: `python -m pendify`.
109
110
  - Stop: press «Quit» on the program's page; if it is not open, starting the program again opens it. As a last resort, end the `pythonw.exe` process in Task Manager.
111
+ - Icon by the clock: a click on the PendiFy icon by the clock opens a menu that opens the page, pauses or resumes the alerts and quits the program, also with the page and the browser closed.
110
112
  - Pause: «Pause alerts», on the page's «This PC» card, stops reading the game client: it accepts no match and sends no alert to your phone, and the page stays open. «Resume alerts» reads it again. The pause is not kept: every start of the program is active again.
111
113
  - Start with Windows: the «Start with Windows» switch, on the same card, is off until you turn it on. When it is on, the program starts by itself when you sign in to Windows, for your user only, without opening the browser or the page.
112
114
  - Activity: the page's «Activity» card lists what the program did since it started: the game client found and lost, each phase, the match accepted and the alert sent. It keeps the last 50 lines, in memory only.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pendify"
7
- version = "0.1.2"
7
+ version = "0.1.4"
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"
@@ -6,9 +6,10 @@
6
6
  unless the start fails
7
7
  python -m <package> ping <kind> one alert with the stored pair, one fixed line per answer
8
8
 
9
- The page and the watcher stop together on Ctrl+C, on Ctrl+Break and on the page's quit button, which
10
- removes the run file and exits 0. One instance per config folder: a second start opens the running page
11
- and exits 0, and one made while that program is still closing after its quit waits for it to end and starts.
9
+ 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
+ the icon by the clock, which remove the run file and exit 0. One instance per config folder: a second start
11
+ 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.
12
13
  Where no console is attached (a pythonw start), the line a start ends on is also shown in a
13
14
  message box. `--data-dir`, `--worker` and `--client-lockfile` are the
14
15
  test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
@@ -24,6 +25,7 @@ from pathlib import Path
24
25
 
25
26
  from . import alert, client, config, page, pairing, runfile, watcher, worker
26
27
  from .autostart import Autostart
28
+ from .tray import Tray
27
29
 
28
30
  TICK_SECONDS = 1.0
29
31
  STOP_SECONDS = 5.0
@@ -41,6 +43,8 @@ CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
41
43
  FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
42
44
  _MB_ICONINFORMATION = 0x40
43
45
  _MB_SETFOREGROUND = 0x10000
46
+ # The icon by the clock's picture: pendify.ico beside the modules, the file install.ps1 points the shortcuts at.
47
+ ICON_PATH = Path(__file__).with_name("pendify.ico")
44
48
 
45
49
 
46
50
  def _data_dir(value):
@@ -150,7 +154,7 @@ def _ends(line, code, show):
150
154
  return code
151
155
 
152
156
 
153
- def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart):
157
+ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray):
154
158
  # A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
155
159
  calm = (lambda line: None) if args.quiet else show
156
160
  run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
@@ -192,8 +196,14 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
192
196
  pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
193
197
  on_resume=watch.resume, autostart=autostart, events=watch.events)
194
198
  url = pairing_page.start()
199
+ # The icon by the clock, under --quiet as well: the way to stop a copy the Run key started. Its words are
200
+ # the page's, in the language the page resolves with no browser to ask: the kept choice, else Spanish.
201
+ icon = tray(url, on_open=lambda: opener(url), on_pause=watch.pause, on_resume=watch.resume,
202
+ on_quit=quit_page, paused=lambda: watch.paused,
203
+ words=lambda: page.WORDS[pairing_page.language_of(None)], icon_path=ICON_PATH)
195
204
  watching = threading.Thread(target=watch.run, daemon=True)
196
205
  try:
206
+ icon.start()
197
207
  run.publish(pairing_page.port)
198
208
  print(f"page: {url}", flush=True)
199
209
  if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
@@ -217,6 +227,7 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
217
227
  watching.join(STOP_SECONDS)
218
228
  alerter.flush(STOP_SECONDS)
219
229
  pairing_page.close()
230
+ icon.close()
220
231
  finally:
221
232
  run.release()
222
233
  return 0
@@ -231,10 +242,11 @@ def _break_as_interrupt():
231
242
 
232
243
  def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
233
244
  beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
234
- sleep=time.sleep):
245
+ sleep=time.sleep, tray=Tray):
235
246
  """`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
236
247
  (the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
237
- line reaches anybody; `clock` and `sleep` time the run file's re-reads."""
248
+ 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."""
238
250
  args = _arguments(sys.argv[1:] if argv is None else argv)
239
251
  store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
240
252
  base = getattr(args, "worker", worker.BASE_URL)
@@ -250,7 +262,7 @@ def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT
250
262
  try:
251
263
  return _serve(args, store, base, timeout, opener, stop or threading.Event(),
252
264
  delay or watcher.accept_delay, beep, show, clock, sleep,
253
- autostart if autostart is not None else Autostart())
265
+ autostart if autostart is not None else Autostart(), tray)
254
266
  finally:
255
267
  if previous is not None:
256
268
  signal.signal(signal.SIGBREAK, previous)
@@ -119,6 +119,7 @@ WORDS = {
119
119
  "this_pc": "Este PC",
120
120
  "pause": "Pausar avisos",
121
121
  "resume": "Reanudar avisos",
122
+ "tray_open": "Abrir la página",
122
123
  "autostart_label": "Iniciar con Windows",
123
124
  "autostart_help": "Al encender el PC el programa empieza solo, sin abrir esta página.",
124
125
  "stopped": "El programa se detuvo: ya no vigila el cliente del juego ni envía avisos.",
@@ -200,6 +201,7 @@ WORDS = {
200
201
  "this_pc": "This PC",
201
202
  "pause": "Pause alerts",
202
203
  "resume": "Resume alerts",
204
+ "tray_open": "Open the page",
203
205
  "autostart_label": "Start with Windows",
204
206
  "autostart_help": "When the PC turns on, the program starts by itself, without opening this page.",
205
207
  "stopped": "The program stopped: it no longer watches the game client or sends alerts.",
@@ -399,22 +401,22 @@ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
399
401
  ".mask{display:flex;align-items:flex-end;gap:10px}\n"
400
402
  ".mask .grp{display:flex;align-items:flex-end;gap:2px}\n"
401
403
  ".mask svg{display:block;flex:none}\n"
402
- # Each figure its own move, once every 30 s, for as long as the page is open; still when the system asks
403
- # for less motion.
404
+ # Each figure its own move, one after another, one second apart, once every 15 s (the owner's reading of
405
+ # 2026-10-02), for as long as the page is open; still when the system asks for less motion.
404
406
  "@media (prefers-reduced-motion:no-preference){\n"
405
407
  ".px{transform-origin:50% 100%}\n"
406
- ".px:nth-child(1){animation:px-archer 30s steps(1,end) infinite}\n"
407
- ".px:nth-child(2){animation:px-knight 30s steps(1,end) infinite}\n"
408
- ".px:nth-child(3){animation:px-mage 30s steps(1,end) infinite}\n"
409
- ".px:nth-child(4){animation:px-creature 30s steps(1,end) infinite}\n"
410
- "@keyframes px-archer{0%{transform:none}2%{transform:scaleX(-1)}5%,100%{transform:none}}\n"
411
- "@keyframes px-knight{0%{transform:none}5%{transform:translateX(3px)}6.5%{transform:translateX(6px)}"
412
- "8%{transform:translateX(3px)}9.5%,100%{transform:none}}\n"
413
- "@keyframes px-mage{0%{transform:none}10%{transform:translateY(-3px)}11.5%{transform:translateY(-6px)}"
414
- "16%{transform:translateY(-3px)}17.5%,100%{transform:none}}\n"
415
- "@keyframes px-creature{0%{transform:none}18%{transform:translate(3px,-6px)}"
416
- "19%{transform:translate(3px,-3px)}20%{transform:translate(0,-6px)}21%{transform:translate(0,-3px)}"
417
- "22%{transform:translate(-3px,-6px)}23%{transform:translate(-3px,-3px)}24%,100%{transform:none}}}\n"
408
+ ".px:nth-child(1){animation:px-archer 15s steps(1,end) infinite}\n"
409
+ ".px:nth-child(2){animation:px-knight 15s steps(1,end) infinite}\n"
410
+ ".px:nth-child(3){animation:px-mage 15s steps(1,end) infinite}\n"
411
+ ".px:nth-child(4){animation:px-creature 15s steps(1,end) infinite}\n"
412
+ "@keyframes px-archer{0%{transform:none}4%{transform:scaleX(-1)}10%,100%{transform:none}}\n"
413
+ "@keyframes px-knight{0%{transform:none}16.67%{transform:translateX(3px)}19.67%{transform:translateX(6px)}"
414
+ "22.67%{transform:translateX(3px)}25.67%,100%{transform:none}}\n"
415
+ "@keyframes px-mage{0%{transform:none}32.33%{transform:translateY(-3px)}35.33%{transform:translateY(-6px)}"
416
+ "44.33%{transform:translateY(-3px)}47.33%,100%{transform:none}}\n"
417
+ "@keyframes px-creature{0%{transform:none}54%{transform:translate(3px,-6px)}"
418
+ "56%{transform:translate(3px,-3px)}58%{transform:translate(0,-6px)}60%{transform:translate(0,-3px)}"
419
+ "62%{transform:translate(-3px,-6px)}64%{transform:translate(-3px,-3px)}66%,100%{transform:none}}}\n"
418
420
  # The typed link and «Olvidar este PC» in one panel, folded.
419
421
  ".fold{margin-top:32px;border-radius:16px;background:var(--card);border:1px solid var(--hair)}\n"
420
422
  ".fold>summary{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:56px;"
@@ -0,0 +1,310 @@
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.
3
+
4
+ Standard library only: user32, shell32 and kernel32 by ctypes, bound for 64-bit Windows. The icon lives on a thread
5
+ of its own, a daemon, with a hidden top-level tool window (never shown, so no taskbar entry and no Alt+Tab slot,
6
+ yet the shell's TaskbarCreated broadcast reaches it) and its message loop; a left or right click on it shows the menu
7
+ in the page's language of that moment. A Win32 call that fails is said in one fixed stderr line, as the page says a
8
+ failed request, and never raises out of the thread: the program runs without an icon rather than dying under
9
+ pythonw. On a platform other than Windows, start() says so in one line and does nothing.
10
+ """
11
+ import ctypes
12
+ import os
13
+ import sys
14
+ import threading
15
+
16
+ TIP = "PendiFy"
17
+ CLASS_NAME = "PendiFyTray"
18
+ THREAD_NAME = "pendify tray"
19
+ CLOSE_SECONDS = 3.0 # how long close() waits for the window to exist, then for the thread to end
20
+ ICON_ID = 1
21
+
22
+ WM_NULL, WM_DESTROY, WM_CLOSE, WM_CONTEXTMENU = 0x0000, 0x0002, 0x0010, 0x007B
23
+ WM_LBUTTONUP, WM_RBUTTONUP, WM_APP = 0x0202, 0x0205, 0x8000
24
+ CALLBACK_MESSAGE = WM_APP + 1
25
+ CLICKS = (WM_LBUTTONUP, WM_RBUTTONUP, WM_CONTEXTMENU)
26
+ WS_OVERLAPPED, WS_EX_TOOLWINDOW = 0x0, 0x80 # the window's style; a tool window takes no taskbar entry
27
+ NIM_ADD, NIM_DELETE = 0, 2
28
+ NIF_MESSAGE, NIF_ICON, NIF_TIP = 0x1, 0x2, 0x4
29
+ IMAGE_ICON, LR_LOADFROMFILE, LR_DEFAULTSIZE = 1, 0x10, 0x40
30
+ MF_STRING, MF_SEPARATOR = 0x0, 0x800
31
+ TPM_RIGHTBUTTON, TPM_NONOTIFY, TPM_RETURNCMD = 0x2, 0x80, 0x100
32
+ OPEN, PAUSE, RESUME, QUIT = 1, 2, 3, 4 # the menu's commands
33
+
34
+ FAILED_LINE = " [tray] {call} failed (error {code})"
35
+ BROKE_LINE = " [tray] the icon broke: {kind}"
36
+ HELD_LINE = " [tray] the icon's thread did not end in {seconds:g} seconds: its icon deleted from close"
37
+ EARLY_LINE = " [tray] the icon's thread did not end in {seconds:g} seconds before its window existed"
38
+ OTHER_PLATFORM_LINE = " [tray] no icon by the clock: this platform is not Windows"
39
+
40
+ # The Win32 types for 64-bit Windows: handles as c_void_p, WPARAM as c_size_t, LPARAM and LRESULT as c_ssize_t.
41
+ HANDLE, UINT, INT, WSTR = ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_wchar_p
42
+ WPARAM, LPARAM, LRESULT = ctypes.c_size_t, ctypes.c_ssize_t, ctypes.c_ssize_t
43
+ # The window procedure's type, made once: LRESULT (HWND, UINT, WPARAM, LPARAM). WINFUNCTYPE exists on Windows only.
44
+ WNDPROC = getattr(ctypes, "WINFUNCTYPE", ctypes.CFUNCTYPE)(LRESULT, HANDLE, UINT, WPARAM, LPARAM)
45
+
46
+
47
+ class POINT(ctypes.Structure):
48
+ _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
49
+
50
+
51
+ class MSG(ctypes.Structure):
52
+ _fields_ = [("hwnd", HANDLE), ("message", UINT), ("wParam", WPARAM), ("lParam", LPARAM), ("time", UINT),
53
+ ("pt", POINT), ("lPrivate", UINT)]
54
+
55
+
56
+ class WNDCLASSW(ctypes.Structure):
57
+ _fields_ = [("style", UINT), ("lpfnWndProc", WNDPROC), ("cbClsExtra", INT), ("cbWndExtra", INT),
58
+ ("hInstance", HANDLE), ("hIcon", HANDLE), ("hCursor", HANDLE), ("hbrBackground", HANDLE),
59
+ ("lpszMenuName", WSTR), ("lpszClassName", WSTR)]
60
+
61
+
62
+ class GUID(ctypes.Structure):
63
+ _fields_ = [("Data1", UINT), ("Data2", ctypes.c_ushort), ("Data3", ctypes.c_ushort),
64
+ ("Data4", ctypes.c_ubyte * 8)]
65
+
66
+
67
+ class NOTIFYICONDATAW(ctypes.Structure):
68
+ _fields_ = [("cbSize", UINT), ("hWnd", HANDLE), ("uID", UINT), ("uFlags", UINT), ("uCallbackMessage", UINT),
69
+ ("hIcon", HANDLE), ("szTip", ctypes.c_wchar * 128), ("dwState", UINT), ("dwStateMask", UINT),
70
+ ("szInfo", ctypes.c_wchar * 256), ("uVersion", UINT), ("szInfoTitle", ctypes.c_wchar * 64),
71
+ ("dwInfoFlags", UINT), ("guidItem", GUID), ("hBalloonIcon", HANDLE)]
72
+
73
+
74
+ class _Win32:
75
+ """The facade: the Win32 functions the icon calls, each bound with its argtypes and restype, and the last
76
+ error of the calling thread."""
77
+
78
+ def __init__(self):
79
+ user32, shell32, kernel32 = (ctypes.WinDLL(name, use_last_error=True)
80
+ for name in ("user32", "shell32", "kernel32"))
81
+ bound = ((kernel32, "GetModuleHandleW", (WSTR,), HANDLE),
82
+ (user32, "RegisterClassW", (ctypes.POINTER(WNDCLASSW),), ctypes.c_ushort),
83
+ (user32, "UnregisterClassW", (WSTR, HANDLE), INT),
84
+ (user32, "CreateWindowExW", (UINT, WSTR, WSTR, UINT, INT, INT, INT, INT, HANDLE, HANDLE, HANDLE,
85
+ HANDLE), HANDLE),
86
+ (user32, "DestroyWindow", (HANDLE,), INT),
87
+ (user32, "DefWindowProcW", (HANDLE, UINT, WPARAM, LPARAM), LRESULT),
88
+ (user32, "GetMessageW", (ctypes.POINTER(MSG), HANDLE, UINT, UINT), INT),
89
+ (user32, "TranslateMessage", (ctypes.POINTER(MSG),), INT),
90
+ (user32, "DispatchMessageW", (ctypes.POINTER(MSG),), LRESULT),
91
+ (user32, "PostMessageW", (HANDLE, UINT, WPARAM, LPARAM), INT),
92
+ (user32, "PostQuitMessage", (INT,), None),
93
+ (user32, "RegisterWindowMessageW", (WSTR,), UINT),
94
+ (user32, "LoadImageW", (HANDLE, WSTR, UINT, INT, INT, UINT), HANDLE),
95
+ (user32, "DestroyIcon", (HANDLE,), INT),
96
+ (user32, "CreatePopupMenu", (), HANDLE),
97
+ (user32, "AppendMenuW", (HANDLE, UINT, WPARAM, WSTR), INT),
98
+ (user32, "DestroyMenu", (HANDLE,), INT),
99
+ (user32, "GetCursorPos", (ctypes.POINTER(POINT),), INT),
100
+ (user32, "SetForegroundWindow", (HANDLE,), INT),
101
+ (user32, "TrackPopupMenu", (HANDLE, UINT, INT, INT, INT, HANDLE, HANDLE), INT),
102
+ (shell32, "Shell_NotifyIconW", (UINT, ctypes.POINTER(NOTIFYICONDATAW)), INT))
103
+ for library, name, arguments, result in bound:
104
+ function = getattr(library, name)
105
+ function.argtypes, function.restype = arguments, result
106
+ setattr(self, name, function)
107
+
108
+ @staticmethod
109
+ def last_error():
110
+ return ctypes.get_last_error()
111
+
112
+
113
+ def _windows():
114
+ return os.name == "nt"
115
+
116
+
117
+ def _say(line):
118
+ """One fixed line on stderr, the page's road for a failure; under pythonw there is no stderr and it goes nowhere."""
119
+ print(line, file=sys.stderr)
120
+
121
+
122
+ class Tray:
123
+ """The icon by the clock. `paused` answers the watcher's state and `words` the page's table for the language set
124
+ 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
+
127
+ def __init__(self, url, on_open, on_pause, on_resume, on_quit, paused, words, icon_path, win32=None):
128
+ self.url = url
129
+ self._on_open, self._on_pause, self._on_resume, self._on_quit = on_open, on_pause, on_resume, on_quit
130
+ self._paused, self._words, self._icon_path = paused, words, str(icon_path)
131
+ self._win32 = win32
132
+ self._wndproc = WNDPROC(self._proc) # made once and kept here, so the window's procedure is never collected
133
+ self._thread = None
134
+ self._ready = threading.Event() # set once the window exists, or once it cannot
135
+ self._instance = self._hwnd = self._icon = None
136
+ self._registered, self._taskbar_created = False, 0
137
+ self._closing = False # set by close(): an _open still under way then adds no icon
138
+
139
+ def start(self):
140
+ """The icon on its own thread, a daemon, so a thread held past close() never keeps the process alive."""
141
+ if self._win32 is None:
142
+ if not _windows():
143
+ _say(OTHER_PLATFORM_LINE)
144
+ return
145
+ try:
146
+ self._win32 = _Win32()
147
+ except (OSError, AttributeError) as failure:
148
+ _say(BROKE_LINE.format(kind=type(failure).__name__))
149
+ return
150
+ self._thread = threading.Thread(target=self._run, name=THREAD_NAME, daemon=True)
151
+ self._thread.start()
152
+
153
+ def close(self):
154
+ """Posts WM_CLOSE to the window when it exists, then waits a few seconds for the thread. A thread still held
155
+ then (in TrackPopupMenu's modal loop, say) leaves no dead icon: close deletes it from the calling thread.
156
+ A thread held before its window existed adds no icon once it goes on, so there is none to delete."""
157
+ self._closing = True
158
+ if self._thread is None:
159
+ return
160
+ self._ready.wait(CLOSE_SECONDS)
161
+ hwnd = self._hwnd
162
+ if hwnd is not None and self._thread.is_alive() and not self._win32.PostMessageW(hwnd, WM_CLOSE, 0, 0):
163
+ self._failed("PostMessageW")
164
+ self._thread.join(CLOSE_SECONDS)
165
+ if self._thread.is_alive():
166
+ if hwnd is not None:
167
+ self._notify(NIM_DELETE, hwnd)
168
+ _say(HELD_LINE.format(seconds=CLOSE_SECONDS))
169
+ else:
170
+ _say(EARLY_LINE.format(seconds=CLOSE_SECONDS))
171
+
172
+ def _failed(self, call):
173
+ _say(FAILED_LINE.format(call=call, code=self._win32.last_error()))
174
+
175
+ def _run(self):
176
+ try:
177
+ if self._open():
178
+ self._loop()
179
+ except Exception as failure: # never out of the thread: the program goes on without its icon
180
+ _say(BROKE_LINE.format(kind=type(failure).__name__))
181
+ finally:
182
+ self._ready.set()
183
+ self._release()
184
+
185
+ def _open(self):
186
+ """The window class, the hidden top-level tool window, the icon's picture and the TaskbarCreated message;
187
+ then the icon. False when there is no window to serve."""
188
+ win32 = self._win32
189
+ self._instance = win32.GetModuleHandleW(None)
190
+ if not self._instance:
191
+ self._failed("GetModuleHandleW")
192
+ window_class = WNDCLASSW(lpfnWndProc=self._wndproc, hInstance=self._instance, lpszClassName=CLASS_NAME)
193
+ self._registered = bool(win32.RegisterClassW(window_class))
194
+ if not self._registered:
195
+ self._failed("RegisterClassW")
196
+ return False
197
+ hwnd = win32.CreateWindowExW(WS_EX_TOOLWINDOW, CLASS_NAME, TIP, WS_OVERLAPPED, 0, 0, 0, 0, None, None,
198
+ self._instance, None) # top-level, never shown: TaskbarCreated reaches it
199
+ if not hwnd:
200
+ self._failed("CreateWindowExW")
201
+ return False
202
+ self._hwnd = hwnd
203
+ self._icon = win32.LoadImageW(None, self._icon_path, IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE)
204
+ if not self._icon:
205
+ self._failed("LoadImageW")
206
+ self._taskbar_created = win32.RegisterWindowMessageW("TaskbarCreated")
207
+ if not self._taskbar_created:
208
+ self._failed("RegisterWindowMessageW")
209
+ if self._closing: # close() has run: no icon, and _release destroys the window and unregisters the class
210
+ return False
211
+ self._notify(NIM_ADD)
212
+ self._ready.set()
213
+ return True
214
+
215
+ def _loop(self):
216
+ win32, message = self._win32, MSG()
217
+ while True:
218
+ got = win32.GetMessageW(message, None, 0, 0)
219
+ if got == 0: # WM_QUIT, posted at WM_DESTROY
220
+ return
221
+ if got == -1:
222
+ self._failed("GetMessageW")
223
+ return
224
+ win32.TranslateMessage(message)
225
+ win32.DispatchMessageW(message)
226
+
227
+ def _release(self):
228
+ """What the thread made, undone as it ends; the window only when its loop ended without WM_CLOSE."""
229
+ win32 = self._win32
230
+ if self._hwnd is not None:
231
+ self._notify(NIM_DELETE)
232
+ if not win32.DestroyWindow(self._hwnd):
233
+ self._failed("DestroyWindow")
234
+ self._hwnd = None
235
+ if self._icon and not win32.DestroyIcon(self._icon):
236
+ self._failed("DestroyIcon")
237
+ self._icon = None
238
+ if self._registered and not win32.UnregisterClassW(CLASS_NAME, self._instance):
239
+ self._failed("UnregisterClassW")
240
+ self._registered = False
241
+
242
+ def _notify(self, action, hwnd=None):
243
+ """The icon added or deleted; `hwnd` names the window when the caller holds it (close, off the thread)."""
244
+ data = NOTIFYICONDATAW(cbSize=ctypes.sizeof(NOTIFYICONDATAW), hWnd=self._hwnd if hwnd is None else hwnd,
245
+ uID=ICON_ID)
246
+ if action == NIM_ADD:
247
+ data.uFlags, data.uCallbackMessage = NIF_MESSAGE | NIF_ICON | NIF_TIP, CALLBACK_MESSAGE
248
+ data.hIcon, data.szTip = self._icon, TIP
249
+ if not self._win32.Shell_NotifyIconW(action, data):
250
+ self._failed("Shell_NotifyIconW")
251
+
252
+ def _proc(self, hwnd, message, wparam, lparam):
253
+ try:
254
+ if message == CALLBACK_MESSAGE:
255
+ if (lparam & 0xFFFF) in CLICKS:
256
+ self._menu(hwnd)
257
+ return 0
258
+ if message == WM_CLOSE:
259
+ self._notify(NIM_DELETE)
260
+ if not self._win32.DestroyWindow(hwnd):
261
+ self._failed("DestroyWindow")
262
+ return 0
263
+ if message == WM_DESTROY:
264
+ self._hwnd = None
265
+ self._win32.PostQuitMessage(0)
266
+ return 0
267
+ if self._taskbar_created and message == self._taskbar_created: # Explorer restarted: the icon again
268
+ self._notify(NIM_ADD)
269
+ return 0
270
+ except Exception as failure: # never out of the procedure: the window goes on serving
271
+ _say(BROKE_LINE.format(kind=type(failure).__name__))
272
+ return 0
273
+ return self._win32.DefWindowProcW(hwnd, message, wparam, lparam)
274
+
275
+ def _menu(self, hwnd):
276
+ """The menu at the cursor, in the words and the pause of this moment, then its command. TrackPopupMenu
277
+ answers 0 when the menu is dismissed, which is no failure."""
278
+ win32, words = self._win32, self._words()
279
+ middle = (RESUME, words["resume"]) if self._paused() else (PAUSE, words["pause"])
280
+ menu = win32.CreatePopupMenu()
281
+ if not menu:
282
+ self._failed("CreatePopupMenu")
283
+ return
284
+ try:
285
+ for item in ((OPEN, words["tray_open"]), middle, None, (QUIT, words["quit"])):
286
+ flags, command, text = (MF_SEPARATOR, 0, None) if item is None else (MF_STRING, *item)
287
+ if not win32.AppendMenuW(menu, flags, command, text):
288
+ self._failed("AppendMenuW")
289
+ cursor = POINT()
290
+ if not win32.GetCursorPos(cursor):
291
+ self._failed("GetCursorPos")
292
+ if not win32.SetForegroundWindow(hwnd): # without it the menu stays open on a click elsewhere
293
+ self._failed("SetForegroundWindow")
294
+ chosen = win32.TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON, cursor.x, cursor.y,
295
+ 0, hwnd, None)
296
+ if not win32.PostMessageW(hwnd, WM_NULL, 0, 0): # lets the menu close on a click elsewhere next time
297
+ self._failed("PostMessageW")
298
+ finally:
299
+ if not win32.DestroyMenu(menu):
300
+ self._failed("DestroyMenu")
301
+ if chosen == OPEN:
302
+ self._on_open()
303
+ elif chosen == PAUSE:
304
+ self._on_pause()
305
+ elif chosen == RESUME:
306
+ self._on_resume()
307
+ elif chosen == QUIT:
308
+ self._on_quit()
309
+ if not win32.PostMessageW(hwnd, WM_CLOSE, 0, 0):
310
+ self._failed("PostMessageW")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pendify
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -61,6 +61,7 @@ El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el pr
61
61
 
62
62
  - Iniciar: el acceso directo `PendiFy`, o `pythonw -m pendify`, que es lo que ejecuta el acceso directo; sin consola, si ya está abierto o no puede iniciarse te lo dice en una ventana. Para verlo en una consola: `python -m pendify`.
63
63
  - Detener: pulsa «Salir» en la página del programa; si no la tienes abierta, iniciarlo otra vez la abre. Como último recurso, cierra el proceso `pythonw.exe` en el Administrador de tareas.
64
+ - Icono junto al reloj: un clic en el icono de PendiFy junto al reloj abre un menú para abrir la página, pausar o reanudar los avisos y salir del programa, también con la página y el navegador cerrados.
64
65
  - Pausar: «Pausar avisos», en la tarjeta «Este PC» de la página, deja de leer el cliente del juego: no acepta partidas ni avisa a tu teléfono, y la página sigue abierta. «Reanudar avisos» vuelve a leerlo. La pausa no se guarda: cada vez que el programa se inicia, empieza activo.
65
66
  - Iniciar con Windows: el interruptor «Iniciar con Windows», en la misma tarjeta, está apagado hasta que lo enciendas. Encendido, el programa empieza solo al iniciar sesión en Windows, solo para tu usuario y sin abrir el navegador ni la página.
66
67
  - Actividad: la tarjeta «Actividad» de la página lista lo que el programa hizo desde que empezó: el cliente del juego encontrado y perdido, cada fase, la partida aceptada y el aviso enviado. Guarda las últimas 50 líneas, solo en memoria.
@@ -123,6 +124,7 @@ The button at the top right switches between the light and the dark theme, and t
123
124
 
124
125
  - Start: the `PendiFy` shortcut, or `pythonw -m pendify`, which is what the shortcut runs; with no console, it tells you in a window when it is already running or cannot start. To see it in a console: `python -m pendify`.
125
126
  - Stop: press «Quit» on the program's page; if it is not open, starting the program again opens it. As a last resort, end the `pythonw.exe` process in Task Manager.
127
+ - Icon by the clock: a click on the PendiFy icon by the clock opens a menu that opens the page, pauses or resumes the alerts and quits the program, also with the page and the browser closed.
126
128
  - Pause: «Pause alerts», on the page's «This PC» card, stops reading the game client: it accepts no match and sends no alert to your phone, and the page stays open. «Resume alerts» reads it again. The pause is not kept: every start of the program is active again.
127
129
  - Start with Windows: the «Start with Windows» switch, on the same card, is off until you turn it on. When it is on, the program starts by itself when you sign in to Windows, for your user only, without opening the browser or the page.
128
130
  - Activity: the page's «Activity» card lists what the program did since it started: the game client found and lost, each phase, the match accepted and the alert sent. It keeps the last 50 lines, in memory only.
@@ -16,6 +16,7 @@ src/pendify/pendify.ico
16
16
  src/pendify/plate_almena.py
17
17
  src/pendify/qr.py
18
18
  src/pendify/runfile.py
19
+ src/pendify/tray.py
19
20
  src/pendify/watcher.py
20
21
  src/pendify/worker.py
21
22
  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
File without changes
File without changes